From 41c750e098262262817bc3e3f1bbf5758a7dffc6 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:14:11 +0200 Subject: [PATCH 01/37] docs(quoter-bot): add kms signing middleware tib Propose moving the maker key's kms:Sign grant from the bot to a policy middleware: an AWS Lambda behind invoke-only IAM that validates structured revoke/quote intents (no crossed books, price bounds, no PnL drop) against its own independent chain reads, canonically encodes them, and only then calls KMS. Bounds full bot-host compromise to in-policy quoting loss plus revoke griefing instead of an unbounded EOA drain. Co-Authored-By: Claude Fable 5 --- docs/INDEX.md | 1 + ...08-12-quoter-bot-kms-signing-middleware.md | 459 ++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100644 docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md diff --git a/docs/INDEX.md b/docs/INDEX.md index af2f84ed..4c4f898f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -57,6 +57,7 @@ _None yet — copy [`templates/DATA-FLOW.md`](./templates/DATA-FLOW.md) into a b - [TIB-2026-06-30: Blue liquidation bot — v0](./decisions/TIB-2026-06-30-blue-liquidation-bot.md) — Morpho Blue ecosystem-backstop liquidator (accrual-aware soltag lens, multi-venue swaps, generic Executor, Railway; the TIB's rindexer discovery has since been replaced by Morpho GraphQL API discovery) — implemented - [TIB-2026-07-09: Midnight market whitelist and venue selection](./decisions/TIB-2026-07-09-midnight-market-and-venue-selection.md) — API-sourced market whitelist + best-of-venues probe selection replacing the hand-maintained routing file; Uniswap dropped as a direct venue — implemented +- [TIB-2026-08-12: Quoter-bot KMS signing policy middleware](./decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md) — an AWS Lambda behind invoke-only IAM becomes the sole `kms:Sign` principal on the maker key; the bot submits structured revoke/quote intents, the Lambda validates no-crossed-books/price-bounds/no-PnL-drop policy against its own independent chain reads and encodes/derives digests itself (sign-what-you-encode), bounding full bot-host compromise to in-policy quoting loss plus revoke griefing _Bot-scoped TIBs move under `packages//docs/decisions/` once a bot lands; proposal TIBs for not-yet-built bots sit in `docs/decisions/` alongside their siblings._ diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md new file mode 100644 index 00000000..b1b3e055 --- /dev/null +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -0,0 +1,459 @@ +# TIB-2026-08-12: Quoter-bot KMS signing policy middleware + +| Field | Value | +| ---------- | --------------- | +| **Status** | Proposed | +| **Date** | 2026-08-12 | +| **Author** | @julien | +| **Scope** | Bot: quoter-bot | + +--- + +## Context + +The quoter bot's `aws` signer identity moved the maker key into AWS KMS: the key is never +exported, and +[`createKmsAccount`](../../bots/quoter-bot/src/infrastructure/make/maker-account.utils.ts) +performs strict SPKI/DER parsing, low-s normalization, and a recovery check against the configured +maker. **Custody is solved. Authorization is not.** KMS receives only an opaque 32-byte keccak +digest (`MessageType: 'DIGEST'`), and IAM controls _who_ may call `kms:Sign` — it has no condition +keys on message content. Any principal holding `kms:Sign`, including a fully compromised bot host, +can therefore obtain a valid maker signature over _anything_: a `transfer`/`approve` transaction +draining the EOA, or an arbitrary EIP-712 payload such as an ERC-2612/Permit2 permit that moves +funds without any maker transaction at all. + +Every existing guard is **in-process** and dies with a compromised process: the bot-kit signer's +default-deny policy check, the offer invariants, and the serialized `MakeService` with its +`NEGATIVE_SPREAD` prospective-book guard +([TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md), §7 and §Security). + +[TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) gates any material capital increase on a +V1 security phase: treasury multisig; delegated funding contract; custom on-chain quoter ratifier; +AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addresses the remaining +**authorization gap** of that custody and is a step in the V1 track. The current hot-key cap is +20,000 USDC per market. + +## Goals / Non-Goals + +**Goals** + +- Make the signing middleware the **only principal** allowed to call `kms:Sign` on the maker key. + The bot loses direct KMS access entirely; its AWS role is reduced to invoking the middleware. +- Replace blind digest signing with **structured intents** (revoke, quote) that the middleware + validates against policy it owns — bounds and pins from its own deployment parameters, book and + position state from its own independent reads. Nothing policy-relevant comes from the request. +- **Sign-what-you-encode**: the middleware canonically encodes each validated intent and derives + the digest internally. The bot never supplies bytes or hashes to be signed. +- Bound the blast radius of full bot-host compromise to a **quantifiable worst-case loss**: + signatures over in-policy offers (≈ worst in-policy rate × capped exposure) plus revocations + (downtime/griefing, no fund loss). No EOA drain, no arbitrary permit. +- Keep revocation the **always-available kill switch**: near-unconditionally approved and the most + available operation the middleware offers. +- Fail closed: quoting halts when middleware invocation fails or an intent is denied. +- Keep the architecture **reproducible by third-party operators** of the public reference bot: the + middleware's code ships in this repo and deploys from a standard container image, and `aws` + custody already presumes an AWS account. + +**Non-Goals** + +- Replacing the in-bot invariants (offer policy, `NEGATIVE_SPREAD`, signer policy guard). They + remain as fast-feedback defense in depth; the middleware is the first control that survives full + bot-host compromise. +- Treasury custody or the delegated funding contract with fund/cap/block controls. Separate V1 + item. +- The custom on-chain quoter ratifier. Complementary track, not replaced by this TIB (see + Alternative 3). +- Generalizing to other bots or keys in v0. The design should not preclude it, but v0 serves one + bot and one maker key. +- Changing KMS/HSM procurement. The existing `ECC_SECG_P256K1` KMS key stays where it is. + +## Current Solution + +- [`maker-account.utils.ts`](../../bots/quoter-bot/src/infrastructure/make/maker-account.utils.ts) + wraps AWS KMS as a viem `LocalAccount`. All four signing surfaces — `sign`, `signMessage`, + `signTypedData`, `signTransaction` — funnel into one `signHash` that calls the KMS `SignCommand` + with `MessageType: 'DIGEST'` and `SigningAlgorithm: 'ECDSA_SHA_256'` on an `ECC_SECG_P256K1` + key. KMS signs whatever digest it is handed. +- [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts) selects + exactly one `MakerIdentity`: `private-key`, `keystore`, `aws` (keyId + region), or read-only. +- Two payload classes are signed by the maker key today + ([quoter-bot README](../../bots/quoter-bot/README.md)): **EIP-712 Ecrecover ratifier offer + trees** published off-chain to the Mempool, and **on-chain transactions** — offer group/root + invalidation, plus `setIsRootRatified` root approval where the Setter ratifier is used. +- In-process guards: [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) runs + `evaluatePolicy` (default-deny: chain, target, selector, value, gas/fee ceilings) between + prepare and broadcast and logs `signer.policy_violation`; the offer invariants and serialized + `MakeService` are documented in [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md). +- Deployment: the bot runs as a Railway service with its own Dockerfile + ([`deploy-railway.ts`](../../bots/quoter-bot/scripts/deploy-railway.ts)). The README instructs + `aws`-mode operators to provision an AWS SDK credential source with KMS access into the service + — exactly the credential this TIB removes from the bot. + +## Proposed Solution + +Insert a policy middleware between the bot and KMS and move the `kms:Sign` grant to it. The +middleware is an **AWS Lambda function** invoked through IAM; the bot's AWS credentials can invoke +it and nothing else. + +```text +bot host (role: invoke-only) signing Lambda (role: kms:Sign + reads) AWS KMS +┌──────────────────────────┐ ┌───────────────────────────────────────┐ ┌───────────┐ +│ quoter-bot │ intent │ 1. validate: crossed books, price │ │ maker key │ +│ · revoke intents │ ──────► │ bounds, PnL, field-level checks │ dgst │ ECC_SECG_ │ +│ · quote intents │ (SigV4) │ 2. canonically encode (EIP-712 / tx) │ ───► │ P256K1 │ +│ │ ◄────── │ 3. derive digest, call kms:Sign │ ◄─── │ sign-only │ +│ no kms:Sign — only │ sig + │ 4. return signature + encoded payload │ sig │ │ +│ lambda:InvokeFunction │ payload └──────────────────┬────────────────────┘ └───────────┘ +└──────────────────────────┘ │ independent live reads + ▼ + RPC + Morpho API/Mempool (never bot-supplied state) +``` + +### 1. Structured intents replace digests + +The wire contract is a **versioned JSON intent** carried as the payload of an AWS SDK +`lambda:InvokeFunction` call. The bot submits one of two intent types; the middleware returns the +signature together with the derived root/tx payload it encoded, so the bot publishes exactly what +was validated. + +**Revoke intents** invalidate offer groups/roots. They are **near-unconditionally approved** — +revocation only reduces exposure and is the always-available kill switch — constrained to: pinned +chain id, `to` = the Midnight singleton, an invalidation-selector allowlist, zero native value, +and fee/gas ceilings. This mirrors the constraint set the in-process signer guard already pins, +now enforced outside the bot. + +**Quote intents** carry an array of structured offers. The set is approved only when **three +properties** all hold: + +1. **No crossed books.** The prospective offer set, evaluated together with the maker's + already-live offers, must not create a negative spread across books — the same whole-book + invariant `MakeService` enforces in-process today, now enforced independently at the signing + boundary. Critically, the middleware does **not** trust bot-supplied book state for this check + — a compromised bot would lie. It reads live offers and chain state itself, through its own + RPC and Morpho API/Mempool reads. +2. **Price bounds.** Every offer's price/rate must remain inside boundaries encoded as parameters + of the middleware's own deployment configuration — never supplied per-request. +3. **No PnL drop.** Publishing the offers must not degrade the maker address's PnL: offer prices + must remain sustainable, i.e. a fill at the offered price must not realize a loss against the + maker's position/cost basis. The exact PnL/cost-basis model and the independent data it needs + are open questions; the property itself is a decided policy requirement. + +Beneath these three headline properties, **field-level validation** on every offer: market +allowlist; per-market and total exposure caps; expiry ≤ market maturity and bounded duration; +exact maker/receiver/callback/ratifier fields; owned group namespace; and cap semantics (exactly +one of `maxUnits`/`maxAssets` non-zero). + +Policy parameters live in the middleware's deployment, never in the request; the state feeding +its checks comes from its own reads, never from the caller. A compromised bot can neither relax +the policy nor lie to it. + +### 2. Sign-what-you-encode + +The middleware itself canonically encodes each validated intent — EIP-712 offer-tree hashing for +Ecrecover ratifier offers, transaction serialization for invalidations — and derives the digest +internally. The bot never supplies bytes or hashes to be signed, so **there is no +decode/re-encode ambiguity to attack**: nothing needs to parse an attacker-supplied encoding and +hope the parse matches what the chain or the ratifier will see. What was validated is what is +signed, by construction. + +### 3. Bot-side seam: intent ports, not a drop-in account + +The viem `LocalAccount.sign(hash)` blind-digest surface is exactly what is being removed, so the +middleware is deliberately **not** a drop-in `LocalAccount` replacement. The bot-side seam is +intent-level ports — an offer-signing port and an invalidation-signing port — backed by +Lambda-invoking adapters, selected as a new identity method alongside +`private-key`/`keystore`/`aws` in +[`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any +residual generic digest-signing path fails closed. + +### 4. Deployment shape: an AWS Lambda behind IAM + +- The middleware is an **AWS Lambda function**, invoked by the bot through the AWS SDK + (`lambda:InvokeFunction`). +- **IAM chain**: the bot's AWS credentials attach to a role whose only permission is + `lambda:InvokeFunction` on this one function ARN — the bot loses `kms:Sign` entirely. The + Lambda's execution role is the only principal with `kms:Sign` on the maker key, plus the + outbound reads its checks need. Creating that execution role and the invoke-only credentials is + part of the deliverable. +- Authentication is therefore **IAM/SigV4** — no self-managed ingress, tokens, or mTLS. + Correctness still does not depend on caller identity: any invoker only obtains in-policy + signatures. Invoke-only scoping exists to prevent revoke-griefing/DoS and to keep the audit + trail attributable — CloudTrail records both the `Invoke` and the `Sign`. +- The Lambda's **code lives in this monorepo** and deploys as a **Docker container image** + (ECR-hosted Lambda container image) with its own Dockerfile, like the bots. It is not a bot — + not a long-running program — so it does not live under `/bots/`; the proposed workspace home is + a new top-level `services/` directory, e.g. `services/quoter-signer`. Final naming and location + are settled at implementation (open question 1). +- Flow: the bot builds the desired offer array → invokes the Lambda with the structured intent → + the Lambda validates the three properties plus field checks, canonically encodes, derives the + digest, calls KMS → returns the signature and encoded payload. Sign-what-you-encode is + unchanged by the deployment shape. + +### 5. Availability posture + +- Lambda and KMS are both **AWS-managed**, which improves the liveness story over a self-hosted + proxy. The middleware remains a liveness dependency: quoting fails closed when invocation + fails; the bot halts publication and retries rather than degrading to any local signing path. +- The **revoke path must be the most available operation** — it is the safety action under + incident conditions. +- **Break-glass revoke is just another IAM principal** granted `lambda:InvokeFunction`: an + operator can invoke the revoke intent directly with their own credentials, with no bot in the + path. + +### 6. Statefulness + +The Lambda is **stateless per invocation**. Its per-invocation checks are computations over chain +truth read at invocation time — the crossed-book and PnL properties already require those +independent reads. Cross-invocation aggregates (e.g. total live signed exposure across successive +quote intents) need either external state or per-invocation chain-truth reads; which of those v0 +adopts is open question 6. + +### 7. Failure posture + +| Failure | Required behavior | +| ----------------------------------------- | ---------------------------------------------------- | +| Invocation fails (throttle, error, limit) | Halt quoting (fail closed) and retry; offers stand | +| Cold start latency | Tolerated; the hourly-ish cadence absorbs it | +| Quote intent denied | Typed rejection, nothing signed; alert if persistent | +| Revoke intent denied | Near-impossible by design; treat as misconfig, alert | +| Independent state read fails (RPC/API) | Fail closed: typed retryable denial, no signature | +| KMS error | Typed failure; never assume a signature was produced | +| Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | +| Unknown intent type/version | Reject; no best-effort interpretation of payloads | + +## Considered Alternatives + +### Alternative 1: Status quo — in-process policy plus direct KMS + +Keep the bot-kit signer policy, offer invariants, and `MakeService` guards, with the bot calling +KMS directly. + +**Why rejected:** The policy and the attacker share a process. A compromised bot host bypasses +every in-process check and still holds `kms:Sign`, and KMS blind-signs digests. The in-process +guards remain valuable as fast feedback, but they are not a security boundary. + +### Alternative 2: AWS-native controls only + +Constrain the existing grant with IAM conditions, KMS grants, and CloudTrail auditing. + +**Why rejected:** No content-based condition keys exist for `kms:Sign` — IAM can pin the algorithm +and message type, not the digest, so it cannot distinguish an in-policy offer from a drain +transaction. CloudTrail is after-the-fact audit, not prevention. + +### Alternative 3: On-chain bounded quoter ratifier only + +Ship the custom ratifier from the V1 track that enforces price/spread bounds on-chain. + +**Why rejected:** Strongest enforcement for offers, but it is a contract build plus audit, and it +cannot constrain the **EOA transaction surface** — preventing fund transfers on-chain needs the +delegated funding contract. The middleware ships sooner, covers both signed payload classes, and +complements rather than replaces the on-chain track. + +### Alternative 4: Self-hosted proxy service + +Run the middleware as a standing service — e.g. a Railway service reached over private networking +with mTLS or a bearer token — the shape an earlier draft of this TIB proposed. + +**Why rejected:** Self-managed authentication, ingress, patching, and a standing server to secure +and keep alive. The Lambda shape is an IAM-native invoke chain in the same trust domain as KMS: +no ingress at all, scale-to-zero, SigV4 authentication for free, and CloudTrail-attributable +calls on both the invoke and the sign. + +### Alternative 5: Custody SaaS with a policy engine + +Adopt a Fireblocks/Turnkey-style provider whose policy engine gates signatures. + +**Why rejected:** Vendor dependency and cost, and it weakens the open-source reference — third +parties must be able to reproduce the architecture. A self-hosted middleware keeps the design +forkable; a custody SaaS remains an option an individual operator may substitute. + +### Alternative 6: Per-make multisig or MPC approval + +Require a second human or MPC quorum signature per make/invalidate. + +**Why rejected:** Latency and operational overhead are incompatible with automated hourly-ish +quoting and one-minute bootstrap monitoring. Multisig remains the right tool for treasury +operations, not per-offer signing. + +### Alternative 7: Nitro-Enclave-attested signing + +Bind the KMS key policy to enclave attestation so only attested middleware code can sign — which +would move signing onto an attested EC2 enclave host in place of the Lambda. + +**Why rejected:** Deferred — elegant, but a heavy operational lift (enclave builds, attestation +management, reproducible images) for v0. Recorded as a possible future hardening of the middleware +host itself; it strengthens rather than changes this design. + +## Assumptions & Constraints + +- IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly one + function ARN and nothing else; the Lambda's execution role is the sole `kms:Sign` principal on + the maker key; break-glass operators hold their own invoke grants. +- Both signed payload classes are fully describable as structured intents and canonically + encodable inside the Lambda (SDK EIP-712 offer-tree hashing; viem transaction serialization). +- The policy surface splits cleanly: price bounds and field pins are **deployment parameters**; + the crossed-book and no-PnL-drop properties are evaluated against the Lambda's **own + independent reads** (RPC and Morpho API/Mempool). The Lambda has network egress to those + sources, and a failed read fails closed. +- One invocation round trip per make/revoke job — including cold starts — is compatible with the + hourly-ish quote cadence and the one-minute bootstrap monitor. +- The Lambda is meaningfully harder to compromise than the bot host — minimal code and + dependencies, an AWS-managed runtime, no ingress, no strategy complexity. That asymmetry is the + premise of moving the root of trust. +- v0 serves one bot and one maker key; the wire contract is versioned so this can widen later. + +## Dependencies + +- AWS KMS `Sign`/`GetPublicKey` on the existing `ECC_SECG_P256K1` maker key + ([AWS KMS Sign API](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html)). +- AWS Lambda (container-image function) and ECR for the image, plus IAM for the invoke-only and + execution role chain + ([Lambda container images](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html)). +- The Lambda's independent read surfaces: an RPC endpoint and the Morpho API/Mempool for live + offers, positions, and chain state. +- `@morpho-org/midnight-sdk` offer-tree EIP-712 hashing for canonical encoding inside the Lambda. +- viem for transaction serialization and signature parsing/verification in the Lambda. +- [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) for the V1 security gate this TIB + advances. + +## Observability + +- The Lambda emits the same JSON-lines structured logging the bots use (to CloudWatch Logs). + Every intent produces a decision event with intent type, evaluated properties and constraints, + the violated check on denial, derived digest, and KMS outcome: `middleware.intent_received`, + `middleware.intent_approved`, `middleware.intent_denied`, `middleware.kms_error`, + `middleware.read_failed`. +- This log is an **authorization audit trail that survives bot-host compromise** — the bot cannot + erase or forge it. +- CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot vs + break-glass principals), and `kms:Sign` has exactly one allowed principal, so the KMS call + stream must match the Lambda's approval log one-to-one. Divergence is an incident signal. +- Alerting on denials, invocation errors/throttles, KMS errors, and independent-read failures. + Bot-side `make.rejected` events extend with middleware-denial reasons; invocation-failure halts + surface through the existing failure events. + +## Security + +**What the middleware stops** (a fully compromised bot host can no longer obtain): + +- Arbitrary transaction signatures — `transfer`/`approve` of the loan asset, calls to unexpected + contracts, value transfers. KMS refuses the bot's principal outright; the Lambda signs only + payloads it encoded from in-policy intents. +- Arbitrary EIP-712 signatures — in particular ERC-2612/Permit2-style permits, which move funds + without any maker transaction. +- Off-policy offers — crossed books, out-of-bounds prices, PnL-degrading quotes, wrong market, + oversized exposure, over-long expiry, foreign receiver/callback/ratifier, foreign group + namespace, malformed cap semantics. +- Lying about book state — the crossed-book and PnL properties are evaluated against the Lambda's + own reads, so a compromised bot cannot feed it a fabricated view. + +**What it does not stop:** + +- **Compromise of the Lambda's code or deployment pipeline** — the new, deliberately minimal root + of trust. Mitigated by a small codebase, minimal dependencies, an AWS-managed runtime with no + ingress, and separate roles; Nitro-attested signing is a recorded future hardening. +- **Policy bugs** — a wrong parameter or check approves what it should not. The policy is small + and exhaustively testable, but it is code. +- **Economically bad but in-policy quoting** — the residual, deliberately accepted exposure: + worst case ≈ worst in-policy rate × capped exposure. Bounded and quantifiable. +- **Misbehavior of the providers behind the Lambda's own reads** — a lying or censoring RPC/API + could wave through a crossed or unsustainable set, or block valid ones. This extends the + provider-trust posture of [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) to the + middleware; the disagreement posture is open question 7. +- **DoS via invocation throttling or concurrency exhaustion** — quoting downtime; resting offers + stand until expiry or revocation through a break-glass invoker. + +Attacker-obtainable revocations are downtime/griefing, not fund loss — and invoke-only IAM +scoping exists precisely to make that griefing hard. + +**Bounded-loss framing.** Today, bot-host compromise means unbounded loss of everything the maker +EOA holds or has approved. With the middleware, it means a bounded, pre-computable number derived +from the policy's price bounds and exposure caps. That conversion — unbounded key authority into a +quantifiable worst case — is a prerequisite for lifting the 20,000 USDC per-market cap under the +[TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) V1 gate, alongside the treasury and +on-chain-ratifier tracks. + +Policy parameters change through the Lambda's own deployment/review path, unreachable from the +bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no policy access. + +## Testing and Verification + +- **Policy:** exhaustive accept/reject vectors for the three properties and every field check on + both intent types, including boundary values — price exactly at a bound, expiry exactly at + maturity, both/neither of `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective + set that crosses only in combination with live offers. +- **Adversarial state:** intents accompanied by caller-supplied book or position state that + contradicts chain truth — the Lambda must ignore the caller's view entirely and decide from its + own reads. +- **Encoding equivalence:** the Lambda-derived EIP-712 digest for a validated offer tree matches + SDK/bot-side hashing for identical structured input; transaction serialization matches viem's + for identical fields. +- **Signature correctness:** recovered signer equals the configured maker across both recovery + parities, reusing the existing strict DER/low-s/recovery-check discipline. +- **Fail-closed negatives:** generic digest-signing requests are rejected; unknown intent + versions are rejected; an independent-read failure produces a typed denial and no KMS call; a + missing or invalid policy configuration refuses to serve. +- **Integration:** bot plus deployed Lambda against a KMS test key — quote publish, revoke, + denial propagation into `make.rejected`, the invocation-failure halt, and a break-glass revoke + invoked by a second IAM principal. +- **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` + after the grant moves, and that its role can invoke nothing but the one function ARN. A denied + call is part of acceptance, not an incident. +- Tests follow the repository verification rule: run each new test, break one assertion to + confirm it fails, restore it. + +## Future Considerations + +- Nitro-Enclave attestation binding the KMS key policy to attested signing code (Alternative 7) + as host hardening. +- Generalizing to other bots and keys — multi-tenant policy keyed by principal and key. +- Gating Setter-ratifier root approvals and setup-remediation transactions if they move from + manual operator actions to automated flows (open question 4). +- When the on-chain bounded ratifier lands, its bounds and the middleware's price policy should + agree; the middleware remains necessary for the transaction surface. +- External state for aggregate exposure accounting if v0 ships with per-invocation chain-truth + checks alone (open question 6). + +## Open Questions + +1. Workspace directory and package naming for the Lambda — proposed `services/quoter-signer` + under a new top-level `services/` directory; settled at implementation. +2. Whether validation logic is shared with bot domain code (one bug affects both — `@repo/offers` + is the natural shared home for the crossed-book model) or independently implemented (drift + risk) — likely a shared schema, independently pinned middleware deployments. +3. The exact PnL/cost-basis model the Lambda evaluates for the no-PnL-drop property, and the + independent data sources it needs. +4. Whether Setter-ratifier root approvals and setup-remediation transactions (approvals) are also + gated through the middleware or stay manual operator actions. +5. Policy parameter change/approval workflow — who reviews, how it deploys, how changes are + audited. +6. Exposure accounting across successive quote intents: stateless per-invocation checks over + chain truth with bounded offer lifetimes, or external state tracking aggregate live signed + exposure. The bounded-loss claim is strongest with aggregate enforcement. +7. Lambda networking/egress design for its independent RPC and Morpho API/Mempool reads — and the + decision posture when those providers disagree with the bot's view of the book. + +## References + +- [TIB-2026-07-27: Midnight ladder quoter-bot — v0](./TIB-2026-07-27-midnight-quoter-bot.md) +- [`maker-account.utils.ts`](../../bots/quoter-bot/src/infrastructure/make/maker-account.utils.ts) + — KMS-backed viem `LocalAccount` +- [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts) — + signer identity selection +- [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) — in-process + default-deny signer policy +- [quoter-bot README](../../bots/quoter-bot/README.md) — signed payload classes and `aws`-mode + deployment +- [Documentation guidance](../GUIDANCE.md) +- [Repository conventions](../CONVENTIONS.md) +- [AWS KMS Sign API](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html) +- [AWS Lambda container images](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html) + + From 2b503805dde4930cb6faa9bbbb52ba30b783f885 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:31:05 +0200 Subject: [PATCH 02/37] docs(quoter-bot): address tib review comments Frame the Lambda as the v0 deployment target behind transport-agnostic intent ports (host swappable to an HTTP API or Cloudflare Worker), and require strong resilience / quorum on the middleware's independent RPC reads. Co-Authored-By: Claude Fable 5 --- ...08-12-quoter-bot-kms-signing-middleware.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index b1b3e055..c9e84f45 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -106,7 +106,7 @@ bot host (role: invoke-only) signing Lambda (role: kms:Sign + reads) │ lambda:InvokeFunction │ payload └──────────────────┬────────────────────┘ └───────────┘ └──────────────────────────┘ │ independent live reads ▼ - RPC + Morpho API/Mempool (never bot-supplied state) + RPC, with strong resilience / quorum + Morpho API/Mempool (never bot-supplied state) ``` ### 1. Structured intents replace digests @@ -161,12 +161,23 @@ signed, by construction. The viem `LocalAccount.sign(hash)` blind-digest surface is exactly what is being removed, so the middleware is deliberately **not** a drop-in `LocalAccount` replacement. The bot-side seam is intent-level ports — an offer-signing port and an invalidation-signing port — backed by -Lambda-invoking adapters, selected as a new identity method alongside +middleware-invoking adapters, selected as a new identity method alongside `private-key`/`keystore`/`aws` in [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. -### 4. Deployment shape: an AWS Lambda behind IAM +The ports are **transport-agnostic**: they express intents, not hosts. The Lambda invoker is one +adapter behind them; plugging in a different middleware host tomorrow — an HTTP API, a Cloudflare +Worker — is an adapter swap that touches no application code. + +### 4. v0 deployment shape: an AWS Lambda behind IAM + +The Lambda is the **v0 deployment target, not part of the contract**. The middleware's policy +core is a host-agnostic validate → encode → sign module with a thin Lambda handler around it, +mirroring how the bot reaches the middleware only through its intent ports. Re-hosting it later — +an HTTP API, a Cloudflare Worker — replaces the handler and the bot-side adapter, not the policy +logic, and any alternative host must preserve the trust split: the middleware alone holds +`kms:Sign`, and callers hold nothing but the right to invoke it. - The middleware is an **AWS Lambda function**, invoked by the bot through the AWS SDK (`lambda:InvokeFunction`). @@ -294,7 +305,9 @@ host itself; it strengthens rather than changes this design. - The policy surface splits cleanly: price bounds and field pins are **deployment parameters**; the crossed-book and no-PnL-drop properties are evaluated against the Lambda's **own independent reads** (RPC and Morpho API/Mempool). The Lambda has network egress to those - sources, and a failed read fails closed. + sources, and a failed read fails closed. The RPC reads need **strong resilience — fallback + providers and/or quorum agreement** — because a single lying or censoring provider is the + remaining way to get a bad set past those two checks (open question 7). - One invocation round trip per make/revoke job — including cold starts — is compatible with the hourly-ish quote cadence and the one-minute bootstrap monitor. - The Lambda is meaningfully harder to compromise than the bot host — minimal code and @@ -309,8 +322,8 @@ host itself; it strengthens rather than changes this design. - AWS Lambda (container-image function) and ECR for the image, plus IAM for the invoke-only and execution role chain ([Lambda container images](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html)). -- The Lambda's independent read surfaces: an RPC endpoint and the Morpho API/Mempool for live - offers, positions, and chain state. +- The Lambda's independent read surfaces: resilient RPC access (fallback and/or quorum across + providers) and the Morpho API/Mempool for live offers, positions, and chain state. - `@morpho-org/midnight-sdk` offer-tree EIP-712 hashing for canonical encoding inside the Lambda. - viem for transaction serialization and signature parsing/verification in the Lambda. - [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) for the V1 security gate this TIB From 857f63f8080753ae3a3b256fff9277dd9e204e8b Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:15:50 +0200 Subject: [PATCH 03/37] docs(quoter-bot): address codex tib findings Decide six review points: aggregate live-exposure enforcement is a required policy element; a ratify intent covers Setter setIsRootRatified approvals; invoke surfaces are scoped per intent type so break-glass principals can only revoke; a freshness ceiling bounds stockpiled signatures; transaction nonces stay caller-owned under a single-writer rule; and the CloudTrail data-event selector for Lambda Invoke is an explicit deliverable. Co-Authored-By: Claude Fable 5 --- ...08-12-quoter-bot-kms-signing-middleware.md | 140 ++++++++++++------ 1 file changed, 95 insertions(+), 45 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index c9e84f45..697e5833 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -39,14 +39,14 @@ AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addre - Make the signing middleware the **only principal** allowed to call `kms:Sign` on the maker key. The bot loses direct KMS access entirely; its AWS role is reduced to invoking the middleware. -- Replace blind digest signing with **structured intents** (revoke, quote) that the middleware +- Replace blind digest signing with **structured intents** (revoke, quote, ratify) that the middleware validates against policy it owns — bounds and pins from its own deployment parameters, book and position state from its own independent reads. Nothing policy-relevant comes from the request. - **Sign-what-you-encode**: the middleware canonically encodes each validated intent and derives the digest internally. The bot never supplies bytes or hashes to be signed. - Bound the blast radius of full bot-host compromise to a **quantifiable worst-case loss**: - signatures over in-policy offers (≈ worst in-policy rate × capped exposure) plus revocations - (downtime/griefing, no fund loss). No EOA drain, no arbitrary permit. + signatures over in-policy offers (≈ worst in-policy rate × capped **aggregate** live exposure) + plus revocations (downtime/griefing, no fund loss). No EOA drain, no arbitrary permit. - Keep revocation the **always-available kill switch**: near-unconditionally approved and the most available operation the middleware offers. - Fail closed: quoting halts when middleware invocation fails or an intent is denied. @@ -99,8 +99,8 @@ it and nothing else. bot host (role: invoke-only) signing Lambda (role: kms:Sign + reads) AWS KMS ┌──────────────────────────┐ ┌───────────────────────────────────────┐ ┌───────────┐ │ quoter-bot │ intent │ 1. validate: crossed books, price │ │ maker key │ -│ · revoke intents │ ──────► │ bounds, PnL, field-level checks │ dgst │ ECC_SECG_ │ -│ · quote intents │ (SigV4) │ 2. canonically encode (EIP-712 / tx) │ ───► │ P256K1 │ +│ · quote intents │ ──────► │ bounds, PnL, field-level checks │ dgst │ ECC_SECG_ │ +│ · revoke/ratify intents │ (SigV4) │ 2. canonically encode (EIP-712 / tx) │ ───► │ P256K1 │ │ │ ◄────── │ 3. derive digest, call kms:Sign │ ◄─── │ sign-only │ │ no kms:Sign — only │ sig + │ 4. return signature + encoded payload │ sig │ │ │ lambda:InvokeFunction │ payload └──────────────────┬────────────────────┘ └───────────┘ @@ -112,7 +112,7 @@ bot host (role: invoke-only) signing Lambda (role: kms:Sign + reads) ### 1. Structured intents replace digests The wire contract is a **versioned JSON intent** carried as the payload of an AWS SDK -`lambda:InvokeFunction` call. The bot submits one of two intent types; the middleware returns the +`lambda:InvokeFunction` call. The bot submits one of three intent types; the middleware returns the signature together with the derived root/tx payload it encoded, so the bot publishes exactly what was validated. @@ -122,6 +122,14 @@ chain id, `to` = the Midnight singleton, an invalidation-selector allowlist, zer and fee/gas ceilings. This mirrors the constraint set the in-process signer guard already pins, now enforced outside the bot. +Because a signed transaction commits to an account nonce, transaction-signing intents carry +**caller-supplied nonce and fee fields** — liveness parameters, not policy: no nonce value can +move funds, only strand or replace a transaction. The single-writer rule is unchanged from today: +the bot's serialized make/pending queue owns the nonce cursor in routine operation, and a +break-glass revocation deliberately takes over the account's transaction stream during an +incident — concurrent same-nonce signatures resolve on-chain as fee-bump replacements, and the +safety revocation is the one that must win. + **Quote intents** carry an array of structured offers. The set is approved only when **three properties** all hold: @@ -139,9 +147,21 @@ properties** all hold: are open questions; the property itself is a decided policy requirement. Beneath these three headline properties, **field-level validation** on every offer: market -allowlist; per-market and total exposure caps; expiry ≤ market maturity and bounded duration; -exact maker/receiver/callback/ratifier fields; owned group namespace; and cap semantics (exactly -one of `maxUnits`/`maxAssets` non-zero). +allowlist; per-market and total exposure caps, enforced against **aggregate live signed +exposure** — the proposed set plus the maker's already-live offers from the middleware's own +reads, never per-intent amounts alone; expiry ≤ market maturity and inside a **freshness +ceiling** — a policy parameter capping offer lifetime from signing time, so a compromised host +cannot stockpile a signature and publish it usefully after the checked state has moved; offer +start not meaningfully before signing time; exact maker/receiver/callback/ratifier fields; owned +group namespace; and cap semantics (exactly one of `maxUnits`/`maxAssets` non-zero). + +**Ratify intents** exist for Setter-ratifier deployments, whose ladder flow must send +`setIsRootRatified` before a quote tree becomes takeable. A ratify intent carries the same +structured offer set as a quote intent; the middleware re-validates it in full, re-derives the +root itself, and signs only the `setIsRootRatified(maker, root, true)` transaction for that +derived root — under the same chain/target/value/fee pins as revocations. Statelessness is +preserved because the middleware never needs to remember which roots it produced: it recomputes +them. Ecrecover deployments never use this intent. Policy parameters live in the middleware's deployment, never in the request; the state feeding its checks comes from its own reads, never from the caller. A compromised bot can neither relax @@ -160,8 +180,9 @@ signed, by construction. The viem `LocalAccount.sign(hash)` blind-digest surface is exactly what is being removed, so the middleware is deliberately **not** a drop-in `LocalAccount` replacement. The bot-side seam is -intent-level ports — an offer-signing port and an invalidation-signing port — backed by -middleware-invoking adapters, selected as a new identity method alongside +intent-level ports — an offer-signing port, an invalidation-signing port, and a root-ratification +port for Setter deployments — backed by middleware-invoking adapters, selected as a new identity +method alongside `private-key`/`keystore`/`aws` in [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. @@ -181,15 +202,23 @@ logic, and any alternative host must preserve the trust split: the middleware al - The middleware is an **AWS Lambda function**, invoked by the bot through the AWS SDK (`lambda:InvokeFunction`). -- **IAM chain**: the bot's AWS credentials attach to a role whose only permission is - `lambda:InvokeFunction` on this one function ARN — the bot loses `kms:Sign` entirely. The - Lambda's execution role is the only principal with `kms:Sign` on the maker key, plus the - outbound reads its checks need. Creating that execution role and the invoke-only credentials is - part of the deliverable. -- Authentication is therefore **IAM/SigV4** — no self-managed ingress, tokens, or mTLS. - Correctness still does not depend on caller identity: any invoker only obtains in-policy - signatures. Invoke-only scoping exists to prevent revoke-griefing/DoS and to keep the audit - trail attributable — CloudTrail records both the `Invoke` and the `Sign`. +- **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are + `lambda:InvokeFunction` on this function's intent surfaces — the bot loses `kms:Sign` entirely. + The Lambda's execution role is the only principal with `kms:Sign` on the maker key, plus the + outbound reads its checks need. Creating that execution role, the invoke-only credentials, and + the CloudTrail data-event selector for the function (see Observability) is part of the + deliverable. +- **Caller-to-intent scoping**: principals are authorized per intent type, not merely per + function. The invoke surface is split by intent class — separate qualified ARNs (per-alias or + per-function) for quote signing and for transaction signing (revoke/ratify) — so IAM grants + scope each principal to the intent types it may submit, and the handler independently enforces + the scope it was invoked under. Break-glass principals receive the revoke surface only: leaked + break-glass credentials must yield revocations, never signed quotes. +- Authentication is therefore **IAM/SigV4** — no self-managed ingress, tokens, or mTLS. The + in-policy guarantee still does not depend on caller identity — any invoker only ever obtains + in-policy signatures — while the caller-to-intent scoping above decides _which_ in-policy + intents a given principal may submit, and invoke scoping keeps revoke-griefing/DoS hard and the + audit trail attributable. - The Lambda's **code lives in this monorepo** and deploys as a **Docker container image** (ECR-hosted Lambda container image) with its own Dockerfile, like the bots. It is not a bot — not a long-running program — so it does not live under `/bots/`; the proposed workspace home is @@ -207,17 +236,22 @@ logic, and any alternative host must preserve the trust split: the middleware al fails; the bot halts publication and retries rather than degrading to any local signing path. - The **revoke path must be the most available operation** — it is the safety action under incident conditions. -- **Break-glass revoke is just another IAM principal** granted `lambda:InvokeFunction`: an +- **Break-glass revoke is just another IAM principal** granted the revoke invoke surface: an operator can invoke the revoke intent directly with their own credentials, with no bot in the - path. + path — and that surface cannot produce quote signatures. ### 6. Statefulness The Lambda is **stateless per invocation**. Its per-invocation checks are computations over chain truth read at invocation time — the crossed-book and PnL properties already require those -independent reads. Cross-invocation aggregates (e.g. total live signed exposure across successive -quote intents) need either external state or per-invocation chain-truth reads; which of those v0 -adopts is open question 6. +independent reads, and ratify intents recompute roots rather than remembering them. **Aggregate +live-exposure enforcement is required, not optional**: every quote intent is evaluated against +the maker's live offer set from the middleware's own reads, so successive individually-in-policy +intents cannot compound past the aggregate caps, and the freshness ceiling bounds the +signed-but-unpublished exposure those reads cannot yet see. Whether v0 implements that accounting +purely through per-invocation chain-truth reads or adds external state is open question 6. +Transaction nonces stay caller-owned (see the intent contract), so statelessness never requires +the middleware to coordinate an account's transaction stream. ### 7. Failure posture @@ -227,6 +261,7 @@ adopts is open question 6. | Cold start latency | Tolerated; the hourly-ish cadence absorbs it | | Quote intent denied | Typed rejection, nothing signed; alert if persistent | | Revoke intent denied | Near-impossible by design; treat as misconfig, alert | +| Concurrent tx signers (bot + break-glass) | Same-nonce fee-bump replacement resolves on-chain | | Independent state read fails (RPC/API) | Fail closed: typed retryable denial, no signature | | KMS error | Typed failure; never assume a signature was produced | | Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | @@ -297,9 +332,10 @@ host itself; it strengthens rather than changes this design. ## Assumptions & Constraints -- IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly one - function ARN and nothing else; the Lambda's execution role is the sole `kms:Sign` principal on - the maker key; break-glass operators hold their own invoke grants. +- IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly + the intent surfaces it needs and nothing else; the Lambda's execution role is the sole + `kms:Sign` principal on the maker key; break-glass operators hold revoke-surface invoke grants + only. - Both signed payload classes are fully describable as structured intents and canonically encodable inside the Lambda (SDK EIP-712 offer-tree hashing; viem transaction serialization). - The policy surface splits cleanly: price bounds and field pins are **deployment parameters**; @@ -341,6 +377,10 @@ host itself; it strengthens rather than changes this design. - CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot vs break-glass principals), and `kms:Sign` has exactly one allowed principal, so the KMS call stream must match the Lambda's approval log one-to-one. Divergence is an incident signal. + Lambda `Invoke` is a CloudTrail **data event and is not logged by default** + ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); + enabling the data-event selector for this function is an explicit v0 deliverable — without it, + the invoke side of this audit trail silently does not exist. - Alerting on denials, invocation errors/throttles, KMS errors, and independent-read failures. Bot-side `make.rejected` events extend with middleware-denial reasons; invocation-failure halts surface through the existing failure events. @@ -355,8 +395,8 @@ host itself; it strengthens rather than changes this design. - Arbitrary EIP-712 signatures — in particular ERC-2612/Permit2-style permits, which move funds without any maker transaction. - Off-policy offers — crossed books, out-of-bounds prices, PnL-degrading quotes, wrong market, - oversized exposure, over-long expiry, foreign receiver/callback/ratifier, foreign group - namespace, malformed cap semantics. + per-intent or aggregate over-exposure, expiry beyond the freshness ceiling, foreign + receiver/callback/ratifier, foreign group namespace, malformed cap semantics. - Lying about book state — the crossed-book and PnL properties are evaluated against the Lambda's own reads, so a compromised bot cannot feed it a fabricated view. @@ -368,7 +408,11 @@ host itself; it strengthens rather than changes this design. - **Policy bugs** — a wrong parameter or check approves what it should not. The policy is small and exhaustively testable, but it is code. - **Economically bad but in-policy quoting** — the residual, deliberately accepted exposure: - worst case ≈ worst in-policy rate × capped exposure. Bounded and quantifiable. + worst case ≈ worst in-policy rate × capped aggregate exposure. Bounded and quantifiable. This + includes delayed publication: a stockpiled signature stays publishable until its expiry, so the + freshness ceiling is what keeps "in-policy at signing time" close to "in-policy at publication + time"; middleware-direct publication and ratifier/Mempool-enforced freshness are recorded + hardenings. - **Misbehavior of the providers behind the Lambda's own reads** — a lying or censoring RPC/API could wave through a crossed or unsustainable set, or block valid ones. This extends the provider-trust posture of [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) to the @@ -392,9 +436,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no ## Testing and Verification - **Policy:** exhaustive accept/reject vectors for the three properties and every field check on - both intent types, including boundary values — price exactly at a bound, expiry exactly at - maturity, both/neither of `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective - set that crosses only in combination with live offers. + all three intent types, including boundary values — price exactly at a bound, expiry exactly at + maturity or the freshness ceiling, aggregate exposure that overflows only in combination with + live offers, a ratify root that does not match its offer set, both/neither of + `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective set that crosses only in + combination with live offers. - **Adversarial state:** intents accompanied by caller-supplied book or position state that contradicts chain truth — the Lambda must ignore the caller's view entirely and decide from its own reads. @@ -410,8 +456,9 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no denial propagation into `make.rejected`, the invocation-failure halt, and a break-glass revoke invoked by a second IAM principal. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` - after the grant moves, and that its role can invoke nothing but the one function ARN. A denied - call is part of acceptance, not an incident. + after the grant moves, that each role can invoke only its granted intent surfaces, and that a + break-glass principal is denied on the quote surface. A denied call is part of acceptance, not + an incident. - Tests follow the repository verification rule: run each new test, break one assertion to confirm it fails, restore it. @@ -420,12 +467,14 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no - Nitro-Enclave attestation binding the KMS key policy to attested signing code (Alternative 7) as host hardening. - Generalizing to other bots and keys — multi-tenant policy keyed by principal and key. -- Gating Setter-ratifier root approvals and setup-remediation transactions if they move from - manual operator actions to automated flows (open question 4). +- Gating setup-remediation transactions (token approvals) if they move from manual operator + actions to automated flows (open question 4). - When the on-chain bounded ratifier lands, its bounds and the middleware's price policy should agree; the middleware remains necessary for the transaction surface. -- External state for aggregate exposure accounting if v0 ships with per-invocation chain-truth - checks alone (open question 6). +- Middleware-direct publication to the Mempool, or ratifier/Mempool-enforced signature freshness, + to shrink the delayed-publication residual below the freshness ceiling. +- External state for aggregate exposure accounting if per-invocation chain-truth reads prove + insufficient (open question 6). ## Open Questions @@ -436,13 +485,14 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no risk) — likely a shared schema, independently pinned middleware deployments. 3. The exact PnL/cost-basis model the Lambda evaluates for the no-PnL-drop property, and the independent data sources it needs. -4. Whether Setter-ratifier root approvals and setup-remediation transactions (approvals) are also - gated through the middleware or stay manual operator actions. +4. Whether setup-remediation transactions (token approvals) are also gated through the middleware + or stay manual operator actions. Setter root approvals are decided: they are the ratify + intent. 5. Policy parameter change/approval workflow — who reviews, how it deploys, how changes are audited. -6. Exposure accounting across successive quote intents: stateless per-invocation checks over - chain truth with bounded offer lifetimes, or external state tracking aggregate live signed - exposure. The bounded-loss claim is strongest with aggregate enforcement. +6. The mechanism for the required aggregate live-exposure accounting: per-invocation chain-truth + reads over the same surface as the crossed-book check (with the freshness ceiling bounding + signed-but-unpublished exposure), or external state tracking signed exposure directly. 7. Lambda networking/egress design for its independent RPC and Morpho API/Mempool reads — and the decision posture when those providers disagree with the bot's view of the book. From 8124f939ca49d04521189109f7aff1e6ec6254c5 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:29:35 +0200 Subject: [PATCH 04/37] docs(quoter-bot): address codex round-two tib findings Decide the six round-two review points: publication is itself an on-chain Mempool transaction, so quote intents return the signed tree plus the signed publication transaction; a required persistent reservation ledger makes aggregate caps cover signed-but-unpublished exposure; quote, ratify, and revoke become three separately granted invoke surfaces; a rolling signed-gas budget caps native-gas grief; quote intents declare replaced groups so replacements validate net of the offers they retire; and all policy reads pin to one deterministic snapshot or fail closed. Co-Authored-By: Claude Fable 5 --- ...08-12-quoter-bot-kms-signing-middleware.md | 175 +++++++++++------- 1 file changed, 109 insertions(+), 66 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 697e5833..b821dab9 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -45,8 +45,9 @@ AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addre - **Sign-what-you-encode**: the middleware canonically encodes each validated intent and derives the digest internally. The bot never supplies bytes or hashes to be signed. - Bound the blast radius of full bot-host compromise to a **quantifiable worst-case loss**: - signatures over in-policy offers (≈ worst in-policy rate × capped **aggregate** live exposure) - plus revocations (downtime/griefing, no fund loss). No EOA drain, no arbitrary permit. + signatures over in-policy offers (≈ worst in-policy rate × capped **aggregate signed** + exposure, published or withheld) plus revocations (downtime/griefing) and a capped + signed-gas budget for native-token spend. No loan-asset drain, no arbitrary permit. - Keep revocation the **always-available kill switch**: near-unconditionally approved and the most available operation the middleware offers. - Fail closed: quoting halts when middleware invocation fails or an intent is denied. @@ -78,8 +79,11 @@ AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addre exactly one `MakerIdentity`: `private-key`, `keystore`, `aws` (keyId + region), or read-only. - Two payload classes are signed by the maker key today ([quoter-bot README](../../bots/quoter-bot/README.md)): **EIP-712 Ecrecover ratifier offer - trees** published off-chain to the Mempool, and **on-chain transactions** — offer group/root - invalidation, plus `setIsRootRatified` root approval where the Setter ratifier is used. + trees**, and **on-chain transactions** — publication itself is one: + [`production-ladder.ts`](../../bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts) + sends the SDK-encoded offer payload as a zero-value transaction to the Midnight Mempool + contract, alongside offer group/root invalidation and `setIsRootRatified` root approval where + the Setter ratifier is used. - In-process guards: [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) runs `evaluatePolicy` (default-deny: chain, target, selector, value, gas/fee ceilings) between prepare and broadcast and logs `signer.policy_violation`; the offer invariants and serialized @@ -112,8 +116,8 @@ bot host (role: invoke-only) signing Lambda (role: kms:Sign + reads) ### 1. Structured intents replace digests The wire contract is a **versioned JSON intent** carried as the payload of an AWS SDK -`lambda:InvokeFunction` call. The bot submits one of three intent types; the middleware returns the -signature together with the derived root/tx payload it encoded, so the bot publishes exactly what +`lambda:InvokeFunction` call. The bot submits one of three intent types; the middleware returns +the signatures together with the exact payloads it encoded, so the bot broadcasts exactly what was validated. **Revoke intents** invalidate offer groups/roots. They are **near-unconditionally approved** — @@ -130,15 +134,31 @@ break-glass revocation deliberately takes over the account's transaction stream incident — concurrent same-nonce signatures resolve on-chain as fee-bump replacements, and the safety revocation is the one that must win. -**Quote intents** carry an array of structured offers. The set is approved only when **three -properties** all hold: - -1. **No crossed books.** The prospective offer set, evaluated together with the maker's - already-live offers, must not create a negative spread across books — the same whole-book +Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's +native balance one valid cancellation at a time. Transaction-signing intents therefore also draw +on a **rolling signed-gas budget** — a policy parameter tracked in the reservation ledger (see +Statefulness) across publication, ratification, and revocation signatures — sized comfortably +above a full-book cleanup so the kill switch never starves, and alerting well before exhaustion. +Native-gas grief is thereby capped and enters the bounded-loss arithmetic. + +**Quote intents** carry an array of structured offers plus the **owned group/root IDs the new +set replaces** — normal ladder reconciliation is a replacement, so the prospective book is +evaluated net of the groups the caller commits to invalidate, and ordinary resize/reprice +intents are not denied for crossing the very offers they retire. Declared replaced groups must +decode to the maker's own strategy namespace, and their exposure stays reserved until the +invalidation is observed. An approved quote intent returns both signed artifacts the publication +flow needs: the EIP-712 tree signature (Ecrecover) and the signed zero-value publication +transaction to the Midnight Mempool contract, whose calldata the middleware itself encoded from +the validated set. The set is approved only when **three properties** all hold: + +1. **No crossed books.** The prospective offer set — live offers minus declared replaced groups + plus the proposed set — must not create a negative spread across books: the same whole-book invariant `MakeService` enforces in-process today, now enforced independently at the signing boundary. Critically, the middleware does **not** trust bot-supplied book state for this check — a compromised bot would lie. It reads live offers and chain state itself, through its own - RPC and Morpho API/Mempool reads. + RPC and Morpho API/Mempool reads, and every policy read for one intent is pinned to **one + deterministic snapshot** — a single block tag, with API responses carrying consistent + indexed-block metadata. If a coherent snapshot cannot be assembled, the intent fails closed. 2. **Price bounds.** Every offer's price/rate must remain inside boundaries encoded as parameters of the middleware's own deployment configuration — never supplied per-request. 3. **No PnL drop.** Publishing the offers must not degrade the maker address's PnL: offer prices @@ -147,21 +167,22 @@ properties** all hold: are open questions; the property itself is a decided policy requirement. Beneath these three headline properties, **field-level validation** on every offer: market -allowlist; per-market and total exposure caps, enforced against **aggregate live signed -exposure** — the proposed set plus the maker's already-live offers from the middleware's own -reads, never per-intent amounts alone; expiry ≤ market maturity and inside a **freshness -ceiling** — a policy parameter capping offer lifetime from signing time, so a compromised host -cannot stockpile a signature and publish it usefully after the checked state has moved; offer -start not meaningfully before signing time; exact maker/receiver/callback/ratifier fields; owned +allowlist; per-market and total exposure caps, enforced against **aggregate signed exposure** — +the proposed set, the maker's already-live offers from the middleware's own reads, and every +still-outstanding signed-but-unpublished reservation (see Statefulness), never per-intent +amounts alone; expiry ≤ market maturity and inside a **freshness ceiling** — a policy parameter +capping offer lifetime from signing time, so a stockpiled signature dies quickly; offer start +not meaningfully before signing time; exact maker/receiver/callback/ratifier fields; owned group namespace; and cap semantics (exactly one of `maxUnits`/`maxAssets` non-zero). **Ratify intents** exist for Setter-ratifier deployments, whose ladder flow must send `setIsRootRatified` before a quote tree becomes takeable. A ratify intent carries the same structured offer set as a quote intent; the middleware re-validates it in full, re-derives the root itself, and signs only the `setIsRootRatified(maker, root, true)` transaction for that -derived root — under the same chain/target/value/fee pins as revocations. Statelessness is -preserved because the middleware never needs to remember which roots it produced: it recomputes -them. Ecrecover deployments never use this intent. +derived root — under the same chain/target/value/fee pins as revocations. The middleware never +needs to remember which roots it produced: it recomputes them. Because a ratification enables +publication, ratify is a **quote-enabling intent** and is authorized like quote, never granted +to break-glass principals. Ecrecover deployments never use this intent. Policy parameters live in the middleware's deployment, never in the request; the state feeding its checks comes from its own reads, never from the caller. A compromised bot can neither relax @@ -170,7 +191,8 @@ the policy nor lie to it. ### 2. Sign-what-you-encode The middleware itself canonically encodes each validated intent — EIP-712 offer-tree hashing for -Ecrecover ratifier offers, transaction serialization for invalidations — and derives the digest +Ecrecover ratifier offers, SDK payload encoding for Mempool publication calldata, and +transaction serialization for every transaction kind — and derives the digest internally. The bot never supplies bytes or hashes to be signed, so **there is no decode/re-encode ambiguity to attack**: nothing needs to parse an attacker-supplied encoding and hope the parse matches what the chain or the ratifier will see. What was validated is what is @@ -180,9 +202,9 @@ signed, by construction. The viem `LocalAccount.sign(hash)` blind-digest surface is exactly what is being removed, so the middleware is deliberately **not** a drop-in `LocalAccount` replacement. The bot-side seam is -intent-level ports — an offer-signing port, an invalidation-signing port, and a root-ratification -port for Setter deployments — backed by middleware-invoking adapters, selected as a new identity -method alongside +intent-level ports — a quote-publication port (signed tree plus signed publication transaction), +an invalidation-signing port, and a root-ratification port for Setter deployments — backed by +middleware-invoking adapters, selected as a new identity method alongside `private-key`/`keystore`/`aws` in [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. @@ -209,11 +231,12 @@ logic, and any alternative host must preserve the trust split: the middleware al the CloudTrail data-event selector for the function (see Observability) is part of the deliverable. - **Caller-to-intent scoping**: principals are authorized per intent type, not merely per - function. The invoke surface is split by intent class — separate qualified ARNs (per-alias or - per-function) for quote signing and for transaction signing (revoke/ratify) — so IAM grants - scope each principal to the intent types it may submit, and the handler independently enforces - the scope it was invoked under. Break-glass principals receive the revoke surface only: leaked - break-glass credentials must yield revocations, never signed quotes. + function. Each of the three intents is its **own invoke surface** — separate qualified ARNs + (per-alias or per-function) for quote, ratify, and revoke — so IAM grants scope each principal + to the intent types it may submit, and the handler independently enforces the scope it was + invoked under. Ratify is quote-enabling and is granted like quote. Break-glass principals + receive the revoke surface only: leaked break-glass credentials must yield revocations, never + signed quotes or ratifications. - Authentication is therefore **IAM/SigV4** — no self-managed ingress, tokens, or mTLS. The in-policy guarantee still does not depend on caller identity — any invoker only ever obtains in-policy signatures — while the caller-to-intent scoping above decides _which_ in-policy @@ -226,8 +249,8 @@ logic, and any alternative host must preserve the trust split: the middleware al are settled at implementation (open question 1). - Flow: the bot builds the desired offer array → invokes the Lambda with the structured intent → the Lambda validates the three properties plus field checks, canonically encodes, derives the - digest, calls KMS → returns the signature and encoded payload. Sign-what-you-encode is - unchanged by the deployment shape. + digest, calls KMS → returns the signatures and encoded payloads (tree plus publication + transaction for quotes). Sign-what-you-encode is unchanged by the deployment shape. ### 5. Availability posture @@ -242,16 +265,21 @@ logic, and any alternative host must preserve the trust split: the middleware al ### 6. Statefulness -The Lambda is **stateless per invocation**. Its per-invocation checks are computations over chain -truth read at invocation time — the crossed-book and PnL properties already require those -independent reads, and ratify intents recompute roots rather than remembering them. **Aggregate -live-exposure enforcement is required, not optional**: every quote intent is evaluated against -the maker's live offer set from the middleware's own reads, so successive individually-in-policy -intents cannot compound past the aggregate caps, and the freshness ceiling bounds the -signed-but-unpublished exposure those reads cannot yet see. Whether v0 implements that accounting -purely through per-invocation chain-truth reads or adds external state is open question 6. -Transaction nonces stay caller-owned (see the intent contract), so statelessness never requires -the middleware to coordinate an account's transaction stream. +The Lambda's compute is **stateless per invocation** — every check is a computation over pinned +chain truth read at invocation time, and ratify intents recompute roots rather than remembering +them — but aggregate enforcement requires one small piece of **required state: a persistent +reservation ledger**. Chain-truth reads cannot see a signature that was returned but never +published, and the freshness ceiling bounds duration, not amount — without a ledger, repeated +sign-and-withhold requests could multiply exposure far beyond the caps inside one window. Every +approved quote intent therefore records a reservation (markets, exposure, root, expiry) at +signing time; aggregate caps are enforced over live offers **plus outstanding reservations**; a +reservation is released when its root is observed live (it then counts as live) or when its +freshness ceiling passes unpublished, and exposure of declared replaced groups stays reserved +until their invalidation is observed. The ceiling keeps the ledger tiny and self-expiring; +conditional writes serialize concurrent intents; the same ledger tracks the rolling signed-gas +budget. The ledger is what makes the bounded-loss claim hold. Transaction nonces stay +caller-owned (see the intent contract), so the middleware never coordinates the account's +transaction stream. ### 7. Failure posture @@ -262,7 +290,8 @@ the middleware to coordinate an account's transaction stream. | Quote intent denied | Typed rejection, nothing signed; alert if persistent | | Revoke intent denied | Near-impossible by design; treat as misconfig, alert | | Concurrent tx signers (bot + break-glass) | Same-nonce fee-bump replacement resolves on-chain | -| Independent state read fails (RPC/API) | Fail closed: typed retryable denial, no signature | +| Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | +| Reservation ledger unavailable | Quote/ratify fail closed; revoke stays served, alert | | KMS error | Typed failure; never assume a signature was produced | | Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | | Unknown intent type/version | Reject; no best-effort interpretation of payloads | @@ -292,7 +321,7 @@ Ship the custom ratifier from the V1 track that enforces price/spread bounds on- **Why rejected:** Strongest enforcement for offers, but it is a contract build plus audit, and it cannot constrain the **EOA transaction surface** — preventing fund transfers on-chain needs the -delegated funding contract. The middleware ships sooner, covers both signed payload classes, and +delegated funding contract. The middleware ships sooner, covers every signed payload class, and complements rather than replaces the on-chain track. ### Alternative 4: Self-hosted proxy service @@ -336,14 +365,19 @@ host itself; it strengthens rather than changes this design. the intent surfaces it needs and nothing else; the Lambda's execution role is the sole `kms:Sign` principal on the maker key; break-glass operators hold revoke-surface invoke grants only. -- Both signed payload classes are fully describable as structured intents and canonically - encodable inside the Lambda (SDK EIP-712 offer-tree hashing; viem transaction serialization). +- Every signed payload class is fully describable as structured intents and canonically + encodable inside the Lambda (SDK EIP-712 offer-tree hashing and Mempool payload encoding; viem + transaction serialization). - The policy surface splits cleanly: price bounds and field pins are **deployment parameters**; the crossed-book and no-PnL-drop properties are evaluated against the Lambda's **own independent reads** (RPC and Morpho API/Mempool). The Lambda has network egress to those sources, and a failed read fails closed. The RPC reads need **strong resilience — fallback providers and/or quorum agreement** — because a single lying or censoring provider is the - remaining way to get a bad set past those two checks (open question 7). + remaining way to get a bad set past those two checks (open question 7). Reads for one intent + are pinned to a single deterministic snapshot; a mixed-block view is a denial, not an input. +- A small managed store with conditional writes (e.g. DynamoDB) is available to the Lambda for + the reservation ledger and signed-gas budget. It holds no secrets; its unavailability fails + quoting closed while revocation stays served. - One invocation round trip per make/revoke job — including cold starts — is compatible with the hourly-ish quote cadence and the one-minute bootstrap monitor. - The Lambda is meaningfully harder to compromise than the bot host — minimal code and @@ -360,6 +394,8 @@ host itself; it strengthens rather than changes this design. ([Lambda container images](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html)). - The Lambda's independent read surfaces: resilient RPC access (fallback and/or quorum across providers) and the Morpho API/Mempool for live offers, positions, and chain state. +- A small managed state store for the reservation ledger and signed-gas budget (e.g. DynamoDB + with conditional writes). - `@morpho-org/midnight-sdk` offer-tree EIP-712 hashing for canonical encoding inside the Lambda. - viem for transaction serialization and signature parsing/verification in the Lambda. - [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) for the V1 security gate this TIB @@ -395,7 +431,8 @@ host itself; it strengthens rather than changes this design. - Arbitrary EIP-712 signatures — in particular ERC-2612/Permit2-style permits, which move funds without any maker transaction. - Off-policy offers — crossed books, out-of-bounds prices, PnL-degrading quotes, wrong market, - per-intent or aggregate over-exposure, expiry beyond the freshness ceiling, foreign + per-intent or aggregate over-exposure (including sign-and-withhold multiplication, blocked by + the reservation ledger), expiry beyond the freshness ceiling, foreign receiver/callback/ratifier, foreign group namespace, malformed cap semantics. - Lying about book state — the crossed-book and PnL properties are evaluated against the Lambda's own reads, so a compromised bot cannot feed it a fabricated view. @@ -408,11 +445,12 @@ host itself; it strengthens rather than changes this design. - **Policy bugs** — a wrong parameter or check approves what it should not. The policy is small and exhaustively testable, but it is code. - **Economically bad but in-policy quoting** — the residual, deliberately accepted exposure: - worst case ≈ worst in-policy rate × capped aggregate exposure. Bounded and quantifiable. This - includes delayed publication: a stockpiled signature stays publishable until its expiry, so the - freshness ceiling is what keeps "in-policy at signing time" close to "in-policy at publication - time"; middleware-direct publication and ratifier/Mempool-enforced freshness are recorded - hardenings. + worst case ≈ worst in-policy rate × capped aggregate signed exposure. Bounded and + quantifiable. This includes delayed publication — a stockpiled signature stays publishable + until its expiry, with the reservation ledger capping how much can be outstanding and the + freshness ceiling capping for how long — and native-gas spend through valid transaction + signatures, capped by the rolling signed-gas budget. Middleware-direct publication and + ratifier/Mempool-enforced freshness are recorded hardenings. - **Misbehavior of the providers behind the Lambda's own reads** — a lying or censoring RPC/API could wave through a crossed or unsustainable set, or block valid ones. This extends the provider-trust posture of [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) to the @@ -420,8 +458,9 @@ host itself; it strengthens rather than changes this design. - **DoS via invocation throttling or concurrency exhaustion** — quoting downtime; resting offers stand until expiry or revocation through a break-glass invoker. -Attacker-obtainable revocations are downtime/griefing, not fund loss — and invoke-only IAM -scoping exists precisely to make that griefing hard. +Attacker-obtainable revocations are downtime/griefing plus bounded native-gas spend, not +loan-asset loss — per-intent invoke scoping and the signed-gas budget exist precisely to keep +that griefing hard and capped. **Bounded-loss framing.** Today, bot-host compromise means unbounded loss of everything the maker EOA holds or has approved. With the middleware, it means a bounded, pre-computable number derived @@ -438,9 +477,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no - **Policy:** exhaustive accept/reject vectors for the three properties and every field check on all three intent types, including boundary values — price exactly at a bound, expiry exactly at maturity or the freshness ceiling, aggregate exposure that overflows only in combination with - live offers, a ratify root that does not match its offer set, both/neither of - `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective set that crosses only in - combination with live offers. + live offers or outstanding reservations, sign-and-withhold sequences denied once reservations + exhaust the caps, a replacement approved only because it retires the groups it crosses, a + ratify root that does not match its offer set, mixed-snapshot reads denied as incoherent, a + signed-gas budget refusing the next transaction, both/neither of `maxUnits`/`maxAssets` set, + off-by-one exposure caps, a prospective set that crosses only in combination with live offers. - **Adversarial state:** intents accompanied by caller-supplied book or position state that contradicts chain truth — the Lambda must ignore the caller's view entirely and decide from its own reads. @@ -457,8 +498,8 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no invoked by a second IAM principal. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` after the grant moves, that each role can invoke only its granted intent surfaces, and that a - break-glass principal is denied on the quote surface. A denied call is part of acceptance, not - an incident. + break-glass principal is denied on the quote and ratify surfaces. A denied call is part of + acceptance, not an incident. - Tests follow the repository verification rule: run each new test, break one assertion to confirm it fails, restore it. @@ -472,9 +513,8 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no - When the on-chain bounded ratifier lands, its bounds and the middleware's price policy should agree; the middleware remains necessary for the transaction surface. - Middleware-direct publication to the Mempool, or ratifier/Mempool-enforced signature freshness, - to shrink the delayed-publication residual below the freshness ceiling. -- External state for aggregate exposure accounting if per-invocation chain-truth reads prove - insufficient (open question 6). + to shrink the delayed-publication residual below the freshness ceiling and retire the + reservation ledger's unpublished-exposure role. ## Open Questions @@ -490,9 +530,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no intent. 5. Policy parameter change/approval workflow — who reviews, how it deploys, how changes are audited. -6. The mechanism for the required aggregate live-exposure accounting: per-invocation chain-truth - reads over the same surface as the crossed-book check (with the freshness ceiling bounding - signed-but-unpublished exposure), or external state tracking signed exposure directly. +6. The reservation ledger's concrete store and consistency design — DynamoDB conditional writes + are the default candidate — including release on observed publication/invalidation, expiry + eviction, and exactly how ledger unavailability keeps revocation served while quoting fails + closed. 7. Lambda networking/egress design for its independent RPC and Morpho API/Mempool reads — and the decision posture when those providers disagree with the bot's view of the book. @@ -505,6 +546,8 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no signer identity selection - [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) — in-process default-deny signer policy +- [`production-ladder.ts`](../../bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts) + — on-chain Mempool publication, ratification, and invalidation transactions - [quoter-bot README](../../bots/quoter-bot/README.md) — signed payload classes and `aws`-mode deployment - [Documentation guidance](../GUIDANCE.md) From ea6da4469dbbe7b6c68e0dfb4ade265cb28db783 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:36:39 +0200 Subject: [PATCH 05/37] docs(quoter-bot): resolve carapulse blocking contradictions Drop caller-declared replacement exclusions: the prospective book is always observed-live plus proposed, and replacements sequence revoke, observe, then quote like the in-process MakeService. Partition the signed-gas budgets per invoke surface with a protected revoke reserve, define ledger-outage semantics (revoke uncharged under per-transaction ceilings and reserved-concurrency throttling), and name the small funded native balance as the final gas-grief cap. Co-Authored-By: Claude Fable 5 --- ...08-12-quoter-bot-kms-signing-middleware.md | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index b821dab9..c362fb65 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -136,23 +136,33 @@ safety revocation is the one that must win. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw -on a **rolling signed-gas budget** — a policy parameter tracked in the reservation ledger (see -Statefulness) across publication, ratification, and revocation signatures — sized comfortably -above a full-book cleanup so the kill switch never starves, and alerting well before exhaustion. -Native-gas grief is thereby capped and enters the bounded-loss arithmetic. - -**Quote intents** carry an array of structured offers plus the **owned group/root IDs the new -set replaces** — normal ladder reconciliation is a replacement, so the prospective book is -evaluated net of the groups the caller commits to invalidate, and ordinary resize/reprice -intents are not denied for crossing the very offers they retire. Declared replaced groups must -decode to the maker's own strategy namespace, and their exposure stays reserved until the -invalidation is observed. An approved quote intent returns both signed artifacts the publication -flow needs: the EIP-712 tree signature (Ecrecover) and the signed zero-value publication -transaction to the Midnight Mempool contract, whose calldata the middleware itself encoded from -the validated set. The set is approved only when **three properties** all hold: - -1. **No crossed books.** The prospective offer set — live offers minus declared replaced groups - plus the proposed set — must not create a negative spread across books: the same whole-book +on **rolling signed-gas budgets** tracked in the reservation ledger (see Statefulness), and the +budgets are **partitioned by invoke surface**: publication and ratification draw on a routine +budget, while the revoke surface holds its **own protected reserve** — sized for several +full-book cleanups — that routine signing can never draw down, so a compromised bot exhausting +the routine budget cannot starve the kill switch. When the ledger is unavailable, quote/ratify +fail closed while revoke signing continues **uncharged but bounded**: per-transaction ceilings +still apply and the revoke surface is infrastructure-throttled (reserved concurrency), so +worst-case outage spend is rate-limited and the outage itself alerts. The final backstop is that +the maker EOA deliberately holds only a small operational native balance (the existing +`NATIVE_RESERVE_WEI` posture): gas grief can never exceed what is funded. Native-gas spend is +thereby capped and enters the bounded-loss arithmetic. + +**Quote intents** carry an array of structured offers. There are **no caller-declared +exclusions**: the prospective book is always the observed live book plus the proposed set, +because a promised-but-unobserved invalidation is worth nothing at the signing boundary — a +signed publication is broadcastable regardless of what the caller claimed it would revoke +first. A replacement therefore sequences exactly as the in-process `MakeService` already does: +request the revoke signature, broadcast it, wait until the middleware's snapshot shows the old +groups gone, then request the quote signature against the now-clean book. A replacement whose +transitional old-plus-new book stays fully in-policy may skip the wait and revoke after +publication. An approved quote intent returns both signed artifacts the publication flow needs: +the EIP-712 tree signature (Ecrecover) and the signed zero-value publication transaction to the +Midnight Mempool contract, whose calldata the middleware itself encoded from the validated set. +The set is approved only when **three properties** all hold: + +1. **No crossed books.** The prospective offer set — observed live offers plus the proposed + set, with no exclusions — must not create a negative spread across books: the same whole-book invariant `MakeService` enforces in-process today, now enforced independently at the signing boundary. Critically, the middleware does **not** trust bot-supplied book state for this check — a compromised bot would lie. It reads live offers and chain state itself, through its own @@ -274,10 +284,10 @@ sign-and-withhold requests could multiply exposure far beyond the caps inside on approved quote intent therefore records a reservation (markets, exposure, root, expiry) at signing time; aggregate caps are enforced over live offers **plus outstanding reservations**; a reservation is released when its root is observed live (it then counts as live) or when its -freshness ceiling passes unpublished, and exposure of declared replaced groups stays reserved -until their invalidation is observed. The ceiling keeps the ledger tiny and self-expiring; -conditional writes serialize concurrent intents; the same ledger tracks the rolling signed-gas -budget. The ledger is what makes the bounded-loss claim hold. Transaction nonces stay +freshness ceiling passes unpublished. The ceiling keeps the ledger tiny and self-expiring; +conditional writes serialize concurrent intents; the same ledger tracks the per-surface +signed-gas budgets, including the protected revoke reserve. The ledger is what makes the +bounded-loss claim hold. Transaction nonces stay caller-owned (see the intent contract), so the middleware never coordinates the account's transaction stream. @@ -291,7 +301,7 @@ transaction stream. | Revoke intent denied | Near-impossible by design; treat as misconfig, alert | | Concurrent tx signers (bot + break-glass) | Same-nonce fee-bump replacement resolves on-chain | | Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | -| Reservation ledger unavailable | Quote/ratify fail closed; revoke stays served, alert | +| Reservation ledger unavailable | Quote/ratify closed; revoke uncharged + throttled | | KMS error | Typed failure; never assume a signature was produced | | Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | | Unknown intent type/version | Reject; no best-effort interpretation of payloads | @@ -376,8 +386,9 @@ host itself; it strengthens rather than changes this design. remaining way to get a bad set past those two checks (open question 7). Reads for one intent are pinned to a single deterministic snapshot; a mixed-block view is a denial, not an input. - A small managed store with conditional writes (e.g. DynamoDB) is available to the Lambda for - the reservation ledger and signed-gas budget. It holds no secrets; its unavailability fails - quoting closed while revocation stays served. + the reservation ledger and the per-surface signed-gas budgets. It holds no secrets; its + unavailability fails quoting closed while revocation stays served uncharged under + infrastructure throttling. - One invocation round trip per make/revoke job — including cold starts — is compatible with the hourly-ish quote cadence and the one-minute bootstrap monitor. - The Lambda is meaningfully harder to compromise than the bot host — minimal code and @@ -459,8 +470,10 @@ host itself; it strengthens rather than changes this design. stand until expiry or revocation through a break-glass invoker. Attacker-obtainable revocations are downtime/griefing plus bounded native-gas spend, not -loan-asset loss — per-intent invoke scoping and the signed-gas budget exist precisely to keep -that griefing hard and capped. +loan-asset loss — per-intent invoke scoping and the per-surface gas budgets keep that griefing +hard and capped, the protected revoke reserve keeps a compromised routine invoker from starving +break-glass capacity, and the deliberately small funded native balance is the hard ceiling even +during a ledger outage. **Bounded-loss framing.** Today, bot-host compromise means unbounded loss of everything the maker EOA holds or has approved. With the middleware, it means a bounded, pre-computable number derived @@ -478,9 +491,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no all three intent types, including boundary values — price exactly at a bound, expiry exactly at maturity or the freshness ceiling, aggregate exposure that overflows only in combination with live offers or outstanding reservations, sign-and-withhold sequences denied once reservations - exhaust the caps, a replacement approved only because it retires the groups it crosses, a - ratify root that does not match its offer set, mixed-snapshot reads denied as incoherent, a - signed-gas budget refusing the next transaction, both/neither of `maxUnits`/`maxAssets` set, + exhaust the caps, a crossing replacement denied until its retired groups are observed + invalidated and approved after, a ratify root that does not match its offer set, + mixed-snapshot reads denied as incoherent, a routine signed-gas budget refusing the next + publication while the revoke reserve still signs, both/neither of `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective set that crosses only in combination with live offers. - **Adversarial state:** intents accompanied by caller-supplied book or position state that contradicts chain truth — the Lambda must ignore the caller's view entirely and decide from its @@ -532,8 +546,8 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no audited. 6. The reservation ledger's concrete store and consistency design — DynamoDB conditional writes are the default candidate — including release on observed publication/invalidation, expiry - eviction, and exactly how ledger unavailability keeps revocation served while quoting fails - closed. + eviction, per-surface budget partitioning details, and the reserved-concurrency throttle that + bounds uncharged revoke signing during an outage. 7. Lambda networking/egress design for its independent RPC and Morpho API/Mempool reads — and the decision posture when those providers disagree with the bot's view of the book. From a1c07e0f9dfbb394873949225b6ed63ca1619b28 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:42:07 +0200 Subject: [PATCH 06/37] docs(quoter-bot): correct guard and reserve facts per carapulse Describe the quoter's real in-process guards (the ladder transaction assertions before wallet.sendTransaction; bot-kit's evaluatePolicy serves the liquidators and is not on this path), and replace the NATIVE_RESERVE_WEI-as-cap claim with a required native-balance funding ceiling: an explicit operational control with a breach alert, distinct from the minimum readiness threshold. Co-Authored-By: Claude Fable 5 --- ...08-12-quoter-bot-kms-signing-middleware.md | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index c362fb65..cb4e58ae 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -22,10 +22,10 @@ can therefore obtain a valid maker signature over _anything_: a `transfer`/`appr draining the EOA, or an arbitrary EIP-712 payload such as an ERC-2612/Permit2 permit that moves funds without any maker transaction at all. -Every existing guard is **in-process** and dies with a compromised process: the bot-kit signer's -default-deny policy check, the offer invariants, and the serialized `MakeService` with its -`NEGATIVE_SPREAD` prospective-book guard -([TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md), §7 and §Security). +Every existing guard is **in-process** and dies with a compromised process: the offer +invariants, the serialized `MakeService` with its `NEGATIVE_SPREAD` prospective-book guard +([TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md), §7 and §Security), and the quoter's +own transaction assertions applied immediately before each `wallet.sendTransaction`. [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) gates any material capital increase on a V1 security phase: treasury multisig; delegated funding contract; custom on-chain quoter ratifier; @@ -57,7 +57,7 @@ AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addre **Non-Goals** -- Replacing the in-bot invariants (offer policy, `NEGATIVE_SPREAD`, signer policy guard). They +- Replacing the in-bot invariants (offer policy, `NEGATIVE_SPREAD`, transaction assertions). They remain as fast-feedback defense in depth; the middleware is the first control that survives full bot-host compromise. - Treasury custody or the delegated funding contract with fund/cap/block controls. Separate V1 @@ -84,10 +84,16 @@ AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addre sends the SDK-encoded offer payload as a zero-value transaction to the Midnight Mempool contract, alongside offer group/root invalidation and `setIsRootRatified` root approval where the Setter ratifier is used. -- In-process guards: [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) runs - `evaluatePolicy` (default-deny: chain, target, selector, value, gas/fee ceilings) between - prepare and broadcast and logs `signer.policy_violation`; the offer invariants and serialized - `MakeService` are documented in [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md). +- In-process guards: the quoter's signing path builds a wallet directly from + `createMakerAccount` and applies the quoter-specific `assertLadderPublicationTransaction`, + `assertLadderRatificationTransaction`, and `assertLadderCancellationTransaction` checks + ([`ladder-transaction.utils.ts`](../../bots/quoter-bot/src/infrastructure/ladder/ladder-transaction.utils.ts), + [`production-ladder.ts`](../../bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts)) + before each `wallet.sendTransaction`; the offer invariants and serialized `MakeService` are + documented in [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md). The shared + [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) default-deny + `evaluatePolicy` guard serves the liquidation bots and is **not** on the quoter's signing path + today. - Deployment: the bot runs as a Railway service with its own Dockerfile ([`deploy-railway.ts`](../../bots/quoter-bot/scripts/deploy-railway.ts)). The README instructs `aws`-mode operators to provision an AWS SDK credential source with KMS access into the service @@ -123,8 +129,8 @@ was validated. **Revoke intents** invalidate offer groups/roots. They are **near-unconditionally approved** — revocation only reduces exposure and is the always-available kill switch — constrained to: pinned chain id, `to` = the Midnight singleton, an invalidation-selector allowlist, zero native value, -and fee/gas ceilings. This mirrors the constraint set the in-process signer guard already pins, -now enforced outside the bot. +and fee/gas ceilings. This mirrors the constraint set the quoter's in-process transaction +assertions already pin, now enforced outside the bot. Because a signed transaction commits to an account nonce, transaction-signing intents carry **caller-supplied nonce and fee fields** — liveness parameters, not policy: no nonce value can @@ -143,10 +149,12 @@ full-book cleanups — that routine signing can never draw down, so a compromise the routine budget cannot starve the kill switch. When the ledger is unavailable, quote/ratify fail closed while revoke signing continues **uncharged but bounded**: per-transaction ceilings still apply and the revoke surface is infrastructure-throttled (reserved concurrency), so -worst-case outage spend is rate-limited and the outage itself alerts. The final backstop is that -the maker EOA deliberately holds only a small operational native balance (the existing -`NATIVE_RESERVE_WEI` posture): gas grief can never exceed what is funded. Native-gas spend is -thereby capped and enters the bounded-loss arithmetic. +worst-case outage spend is rate-limited and the outage itself alerts. The final backstop is a +**native-balance funding ceiling** — a new operational control this TIB requires, distinct from +the existing `NATIVE_RESERVE_WEI` **minimum** readiness threshold: the operator funds the maker +EOA between that minimum and a configured maximum, and monitoring alerts when the balance +exceeds the maximum. Gas grief can never exceed what is funded. Native-gas spend is thereby +capped and enters the bounded-loss arithmetic. **Quote intents** carry an array of structured offers. There are **no caller-declared exclusions**: the prospective book is always the observed live book plus the proposed set, @@ -310,8 +318,8 @@ transaction stream. ### Alternative 1: Status quo — in-process policy plus direct KMS -Keep the bot-kit signer policy, offer invariants, and `MakeService` guards, with the bot calling -KMS directly. +Keep the in-process guards — offer invariants, `MakeService`, and the quoter's transaction +assertions — with the bot calling KMS directly. **Why rejected:** The policy and the attacker share a process. A compromised bot host bypasses every in-process check and still holds `kms:Sign`, and KMS blind-signs digests. The in-process @@ -389,6 +397,9 @@ host itself; it strengthens rather than changes this design. the reservation ledger and the per-surface signed-gas budgets. It holds no secrets; its unavailability fails quoting closed while revocation stays served uncharged under infrastructure throttling. +- The maker EOA's native balance is operationally bounded: funded above the `NATIVE_RESERVE_WEI` + minimum and below the new configured funding ceiling, with an alert on breach. The gas-grief + hard cap is this operational control, not a protocol guarantee. - One invocation round trip per make/revoke job — including cold starts — is compatible with the hourly-ish quote cadence and the one-minute bootstrap monitor. - The Lambda is meaningfully harder to compromise than the bot host — minimal code and @@ -472,8 +483,8 @@ host itself; it strengthens rather than changes this design. Attacker-obtainable revocations are downtime/griefing plus bounded native-gas spend, not loan-asset loss — per-intent invoke scoping and the per-surface gas budgets keep that griefing hard and capped, the protected revoke reserve keeps a compromised routine invoker from starving -break-glass capacity, and the deliberately small funded native balance is the hard ceiling even -during a ledger outage. +break-glass capacity, and the native-balance funding ceiling — an explicit operational control, +distinct from the `NATIVE_RESERVE_WEI` minimum — is the hard cap even during a ledger outage. **Bounded-loss framing.** Today, bot-host compromise means unbounded loss of everything the maker EOA holds or has approved. With the middleware, it means a bounded, pre-computable number derived @@ -508,8 +519,9 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no versions are rejected; an independent-read failure produces a typed denial and no KMS call; a missing or invalid policy configuration refuses to serve. - **Integration:** bot plus deployed Lambda against a KMS test key — quote publish, revoke, - denial propagation into `make.rejected`, the invocation-failure halt, and a break-glass revoke - invoked by a second IAM principal. + denial propagation into `make.rejected`, the invocation-failure halt, a break-glass revoke + invoked by a second IAM principal, and the funding-ceiling alert when the maker's native + balance exceeds its configured maximum. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` after the grant moves, that each role can invoke only its granted intent surfaces, and that a break-glass principal is denied on the quote and ratify surfaces. A denied call is part of @@ -558,10 +570,12 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no — KMS-backed viem `LocalAccount` - [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts) — signer identity selection -- [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) — in-process - default-deny signer policy +- [`packages/bot-kit/src/signer.ts`](../../packages/bot-kit/src/signer.ts) — shared default-deny + signer used by the liquidation bots; not on the quoter's signing path today - [`production-ladder.ts`](../../bots/quoter-bot/src/infrastructure/ladder/production-ladder.ts) — on-chain Mempool publication, ratification, and invalidation transactions +- [`ladder-transaction.utils.ts`](../../bots/quoter-bot/src/infrastructure/ladder/ladder-transaction.utils.ts) + — the quoter's in-process transaction assertions - [quoter-bot README](../../bots/quoter-bot/README.md) — signed payload classes and `aws`-mode deployment - [Documentation guidance](../GUIDANCE.md) From a3f62ad5a79f488f1ed9658e79d8c435fe25a607 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:44:43 +0200 Subject: [PATCH 07/37] docs(quoter-bot): address codex round-three tib findings Extend the middleware seam to every maker workflow (bootstrap publication rides the same quote intent), pin the per-side reduceOnly flag in field-level policy, and move the KMS audit reconciliation to per-artifact digest granularity since one Ecrecover quote legitimately produces two Sign calls. Co-Authored-By: Claude Fable 5 --- ...08-12-quoter-bot-kms-signing-middleware.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index cb4e58ae..46ac5f3c 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -191,7 +191,9 @@ still-outstanding signed-but-unpublished reservation (see Statefulness), never p amounts alone; expiry ≤ market maturity and inside a **freshness ceiling** — a policy parameter capping offer lifetime from signing time, so a stockpiled signature dies quickly; offer start not meaningfully before signing time; exact maker/receiver/callback/ratifier fields; owned -group namespace; and cap semantics (exactly one of `maxUnits`/`maxAssets` non-zero). +group namespace; the per-side `reduceOnly` flag pinned by policy — the credit-reducing side must +carry `reduceOnly: true`, so a signed sell fill can never turn inventory reduction into new +debt; and cap semantics (exactly one of `maxUnits`/`maxAssets` non-zero). **Ratify intents** exist for Setter-ratifier deployments, whose ladder flow must send `setIsRootRatified` before a quote tree becomes takeable. A ratify intent carries the same @@ -227,6 +229,12 @@ middleware-invoking adapters, selected as a new identity method alongside [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. +The ports serve **every maker workflow, not only the ladder**: position bootstrap (including +auto-refill) signs the same transaction kinds through +[`production-bootstrap.ts`](../../bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts), +and in middleware mode a bootstrap top-up is simply a quote intent in its bootstrap group +namespace — same three properties, same field checks. No workflow retains a generic signer. + The ports are **transport-agnostic**: they express intents, not hosts. The Lambda invoker is one adapter behind them; plugging in a different middleware host tomorrow — an HTTP API, a Cloudflare Worker — is an adapter swap that touches no application code. @@ -427,14 +435,18 @@ host itself; it strengthens rather than changes this design. - The Lambda emits the same JSON-lines structured logging the bots use (to CloudWatch Logs). Every intent produces a decision event with intent type, evaluated properties and constraints, - the violated check on denial, derived digest, and KMS outcome: `middleware.intent_received`, + the violated check on denial, and the **expected KMS call set per signed artifact** — an + approved Ecrecover quote legitimately produces two `kms:Sign` calls (tree and publication + transaction), each logged with its derived digest and outcome: `middleware.intent_received`, `middleware.intent_approved`, `middleware.intent_denied`, `middleware.kms_error`, `middleware.read_failed`. - This log is an **authorization audit trail that survives bot-host compromise** — the bot cannot erase or forge it. - CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot vs break-glass principals), and `kms:Sign` has exactly one allowed principal, so the KMS call - stream must match the Lambda's approval log one-to-one. Divergence is an incident signal. + stream must reconcile against the logged per-artifact digest sets — every `kms:Sign` matches + one expected digest, at artifact rather than intent granularity. An unmatched or surplus call + is an incident signal. Lambda `Invoke` is a CloudTrail **data event and is not logged by default** ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); enabling the data-event selector for this function is an explicit v0 deliverable — without it, @@ -503,9 +515,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no maturity or the freshness ceiling, aggregate exposure that overflows only in combination with live offers or outstanding reservations, sign-and-withhold sequences denied once reservations exhaust the caps, a crossing replacement denied until its retired groups are observed - invalidated and approved after, a ratify root that does not match its offer set, - mixed-snapshot reads denied as incoherent, a routine signed-gas budget refusing the next - publication while the revoke reserve still signs, both/neither of `maxUnits`/`maxAssets` set, + invalidated and approved after, a ratify root that does not match its offer set, a + credit-reducing offer without `reduceOnly` denied, mixed-snapshot reads denied as incoherent, + a routine signed-gas budget refusing the next publication while the revoke reserve still + signs, both/neither of `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective set that crosses only in combination with live offers. - **Adversarial state:** intents accompanied by caller-supplied book or position state that contradicts chain truth — the Lambda must ignore the caller's view entirely and decide from its From 3ce709ff042322b01e1cd55178c3fede71d67c87 Mon Sep 17 00:00:00 2001 From: Julien <61523188+julien-devatom@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:02:15 +0200 Subject: [PATCH 08/37] docs(quoter-bot): address codex round-four tib findings Split the middleware into one Lambda function per intent surface since reserved concurrency is function-scoped; charge exposure reservations on ratify approvals because a ratified root is publishable by any funded sender; split revoke targets (setConsumed on Midnight, cancelRoot on the ratifier); pin middleware-derived continuousFeeCap; and correlate the KMS audit by request ID since CloudTrail Sign events carry no digest. Co-Authored-By: Claude Fable 5 --- ...08-12-quoter-bot-kms-signing-middleware.md | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 46ac5f3c..8e6911d3 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -128,9 +128,10 @@ was validated. **Revoke intents** invalidate offer groups/roots. They are **near-unconditionally approved** — revocation only reduces exposure and is the always-available kill switch — constrained to: pinned -chain id, `to` = the Midnight singleton, an invalidation-selector allowlist, zero native value, -and fee/gas ceilings. This mirrors the constraint set the quoter's in-process transaction -assertions already pin, now enforced outside the bot. +chain id, a **per-operation target/selector allowlist** (group consumption via `setConsumed` on +the Midnight singleton; Ecrecover root cancellation via `cancelRoot(maker, root)` on the +configured ratifier), zero native value, and fee/gas ceilings. This mirrors the constraint set +the quoter's in-process transaction assertions already pin, now enforced outside the bot. Because a signed transaction commits to an account nonce, transaction-signing intents carry **caller-supplied nonce and fee fields** — liveness parameters, not policy: no nonce value can @@ -193,16 +194,21 @@ capping offer lifetime from signing time, so a stockpiled signature dies quickly not meaningfully before signing time; exact maker/receiver/callback/ratifier fields; owned group namespace; the per-side `reduceOnly` flag pinned by policy — the credit-reducing side must carry `reduceOnly: true`, so a signed sell fill can never turn inventory reduction into new -debt; and cap semantics (exactly one of `maxUnits`/`maxAssets` non-zero). +debt; a `continuousFeeCap` the middleware derives from its own snapshot's current market fee — +never unbounded or caller-chosen, so a later fee raise cannot make a fill accept a worse fee +than the PnL check priced; and cap semantics (exactly one of `maxUnits`/`maxAssets` non-zero). **Ratify intents** exist for Setter-ratifier deployments, whose ladder flow must send `setIsRootRatified` before a quote tree becomes takeable. A ratify intent carries the same structured offer set as a quote intent; the middleware re-validates it in full, re-derives the root itself, and signs only the `setIsRootRatified(maker, root, true)` transaction for that -derived root — under the same chain/target/value/fee pins as revocations. The middleware never -needs to remember which roots it produced: it recomputes them. Because a ratification enables -publication, ratify is a **quote-enabling intent** and is authorized like quote, never granted -to break-glass principals. Ecrecover deployments never use this intent. +derived root — under the same chain/value/fee pins as revocations, targeting the configured +ratifier. The middleware never needs to remember which roots it produced: it recomputes them. +Because a ratified root is publishable by **any funded sender** — the Mempool log contract +accepts the encoded payload from any account — a signed ratification is publishable exposure in +itself, so a ratify approval **charges the same exposure reservation as a quote approval**. +Ratify is a **quote-enabling intent**, authorized like quote and never granted to break-glass +principals. Ecrecover deployments never use this intent. Policy parameters live in the middleware's deployment, never in the request; the state feeding its checks comes from its own reads, never from the caller. A compromised bot can neither relax @@ -248,8 +254,8 @@ an HTTP API, a Cloudflare Worker — replaces the handler and the bot-side adapt logic, and any alternative host must preserve the trust split: the middleware alone holds `kms:Sign`, and callers hold nothing but the right to invoke it. -- The middleware is an **AWS Lambda function**, invoked by the bot through the AWS SDK - (`lambda:InvokeFunction`). +- The middleware is a set of **AWS Lambda functions — one per intent surface, built from one + shared container image** — invoked by the bot through the AWS SDK (`lambda:InvokeFunction`). - **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are `lambda:InvokeFunction` on this function's intent surfaces — the bot loses `kms:Sign` entirely. The Lambda's execution role is the only principal with `kms:Sign` on the maker key, plus the @@ -257,19 +263,21 @@ logic, and any alternative host must preserve the trust split: the middleware al the CloudTrail data-event selector for the function (see Observability) is part of the deliverable. - **Caller-to-intent scoping**: principals are authorized per intent type, not merely per - function. Each of the three intents is its **own invoke surface** — separate qualified ARNs - (per-alias or per-function) for quote, ratify, and revoke — so IAM grants scope each principal - to the intent types it may submit, and the handler independently enforces the scope it was - invoked under. Ratify is quote-enabling and is granted like quote. Break-glass principals - receive the revoke surface only: leaked break-glass credentials must yield revocations, never - signed quotes or ratifications. + function. Each of the three intents is its **own Lambda function** — one shared container + image, three deployments. Aliases are not enough: reserved concurrency is function-scoped, so + only separate functions give the revoke surface its own concurrency pool that a quote/ratify + flood cannot exhaust. IAM grants scope each principal to the functions it may invoke, and each + handler independently enforces the intent type it serves. Ratify is quote-enabling and is + granted like quote. Break-glass principals receive the revoke function only: leaked + break-glass credentials must yield revocations, never signed quotes or ratifications. - Authentication is therefore **IAM/SigV4** — no self-managed ingress, tokens, or mTLS. The in-policy guarantee still does not depend on caller identity — any invoker only ever obtains in-policy signatures — while the caller-to-intent scoping above decides _which_ in-policy intents a given principal may submit, and invoke scoping keeps revoke-griefing/DoS hard and the audit trail attributable. -- The Lambda's **code lives in this monorepo** and deploys as a **Docker container image** - (ECR-hosted Lambda container image) with its own Dockerfile, like the bots. It is not a bot — +- The middleware's **code lives in this monorepo** and deploys as one **Docker container image** + (ECR-hosted) instantiated as the three intent functions, with its own Dockerfile, like the + bots. It is not a bot — not a long-running program — so it does not live under `/bots/`; the proposed workspace home is a new top-level `services/` directory, e.g. `services/quoter-signer`. Final naming and location are settled at implementation (open question 1). @@ -297,8 +305,9 @@ them — but aggregate enforcement requires one small piece of **required state: reservation ledger**. Chain-truth reads cannot see a signature that was returned but never published, and the freshness ceiling bounds duration, not amount — without a ledger, repeated sign-and-withhold requests could multiply exposure far beyond the caps inside one window. Every -approved quote intent therefore records a reservation (markets, exposure, root, expiry) at -signing time; aggregate caps are enforced over live offers **plus outstanding reservations**; a +intent that makes exposure publishable — an approved **quote or ratify** — therefore records a +reservation (markets, exposure, root, expiry) at signing time; aggregate caps are enforced over +live offers **plus outstanding reservations**; a reservation is released when its root is observed live (it then counts as live) or when its freshness ceiling passes unpublished. The ceiling keeps the ledger tiny and self-expiring; conditional writes serialize concurrent intents; the same ledger tracks the per-surface @@ -437,16 +446,19 @@ host itself; it strengthens rather than changes this design. Every intent produces a decision event with intent type, evaluated properties and constraints, the violated check on denial, and the **expected KMS call set per signed artifact** — an approved Ecrecover quote legitimately produces two `kms:Sign` calls (tree and publication - transaction), each logged with its derived digest and outcome: `middleware.intent_received`, + transaction), each logged with its derived digest, the **KMS request ID** returned with the + signature, and outcome: `middleware.intent_received`, `middleware.intent_approved`, `middleware.intent_denied`, `middleware.kms_error`, `middleware.read_failed`. - This log is an **authorization audit trail that survives bot-host compromise** — the bot cannot erase or forge it. - CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot vs break-glass principals), and `kms:Sign` has exactly one allowed principal, so the KMS call - stream must reconcile against the logged per-artifact digest sets — every `kms:Sign` matches - one expected digest, at artifact rather than intent granularity. An unmatched or surplus call - is an incident signal. + stream must reconcile against the logged per-artifact signing records. CloudTrail `Sign` + events do not carry the message or digest, so the join key is the **KMS request ID** each + signing record captures: every CloudTrail `Sign` event must match one middleware record and + vice versa, at artifact rather than intent granularity. An unmatched or surplus call on either + side is an incident signal. Lambda `Invoke` is a CloudTrail **data event and is not logged by default** ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); enabling the data-event selector for this function is an explicit v0 deliverable — without it, @@ -514,9 +526,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no all three intent types, including boundary values — price exactly at a bound, expiry exactly at maturity or the freshness ceiling, aggregate exposure that overflows only in combination with live offers or outstanding reservations, sign-and-withhold sequences denied once reservations - exhaust the caps, a crossing replacement denied until its retired groups are observed - invalidated and approved after, a ratify root that does not match its offer set, a - credit-reducing offer without `reduceOnly` denied, mixed-snapshot reads denied as incoherent, + exhaust the caps — through quote or ratify approvals alike, a crossing replacement denied + until its retired groups are observed invalidated and approved after, a ratify root that does + not match its offer set, a credit-reducing offer without `reduceOnly` denied, a caller-supplied + `continuousFeeCap` above the snapshot fee denied, a root revocation targeting Midnight instead + of the ratifier denied, mixed-snapshot reads denied as incoherent, a routine signed-gas budget refusing the next publication while the revoke reserve still signs, both/neither of `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective set that crosses only in combination with live offers. From cad6b73a1c4c1f272d8ae5212531a12960942bbf Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:37:28 +0000 Subject: [PATCH 09/37] docs(quoter-bot): harden reservation policy --- ...08-12-quoter-bot-kms-signing-middleware.md | 63 ++++++++++++------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 8e6911d3..14581d15 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -128,10 +128,12 @@ was validated. **Revoke intents** invalidate offer groups/roots. They are **near-unconditionally approved** — revocation only reduces exposure and is the always-available kill switch — constrained to: pinned -chain id, a **per-operation target/selector allowlist** (group consumption via `setConsumed` on -the Midnight singleton; Ecrecover root cancellation via `cancelRoot(maker, root)` on the +chain id, a **per-operation target/selector and calldata allowlist** (group consumption is exactly +`setConsumed(group, MAX_OFFER_CAP, maker)` on the Midnight singleton, with the configured maker +pinned as `onBehalf`; Ecrecover root cancellation is exactly `cancelRoot(maker, root)` on the configured ratifier), zero native value, and fee/gas ceilings. This mirrors the constraint set -the quoter's in-process transaction assertions already pin, now enforced outside the bot. +the quoter's in-process transaction assertions already pin, now enforced outside the bot; a +compromised revoke invoker cannot spend gas mutating another maker's groups. Because a signed transaction commits to an account nonce, transaction-signing intents carry **caller-supplied nonce and fee fields** — liveness parameters, not policy: no nonce value can @@ -170,13 +172,15 @@ the EIP-712 tree signature (Ecrecover) and the signed zero-value publication tra Midnight Mempool contract, whose calldata the middleware itself encoded from the validated set. The set is approved only when **three properties** all hold: -1. **No crossed books.** The prospective offer set — observed live offers plus the proposed - set, with no exclusions — must not create a negative spread across books: the same whole-book - invariant `MakeService` enforces in-process today, now enforced independently at the signing - boundary. Critically, the middleware does **not** trust bot-supplied book state for this check - — a compromised bot would lie. It reads live offers and chain state itself, through its own - RPC and Morpho API/Mempool reads, and every policy read for one intent is pinned to **one - deterministic snapshot** — a single block tag, with API responses carrying consistent +1. **No crossed books.** The prospective offer set — observed live offers, every still-outstanding + signed-but-unpublished reserved offer, and the proposed set, with no exclusions — must not + create a negative spread across books: the same whole-book invariant `MakeService` enforces + in-process today, now enforced independently at the signing boundary. Reserved offers are + keyed and deduplicated by maker, root, and offer identity, so revalidating the exact same set + does not count it twice. Critically, the middleware does **not** trust bot-supplied book state + for this check — a compromised bot would lie. It reads live offers and chain state itself, + through its own RPC and Morpho API/Mempool reads, and every policy read for one intent is pinned + to **one deterministic snapshot** — a single block tag, with API responses carrying consistent indexed-block metadata. If a coherent snapshot cannot be assembled, the intent fails closed. 2. **Price bounds.** Every offer's price/rate must remain inside boundaries encoded as parameters of the middleware's own deployment configuration — never supplied per-request. @@ -206,9 +210,12 @@ derived root — under the same chain/value/fee pins as revocations, targeting t ratifier. The middleware never needs to remember which roots it produced: it recomputes them. Because a ratified root is publishable by **any funded sender** — the Mempool log contract accepts the encoded payload from any account — a signed ratification is publishable exposure in -itself, so a ratify approval **charges the same exposure reservation as a quote approval**. -Ratify is a **quote-enabling intent**, authorized like quote and never granted to break-glass -principals. Ecrecover deployments never use this intent. +itself. Ratify and publication are therefore one ledgered flow: the first approved intent for an +exact `(maker, root, offer set)` conditionally creates one per-offer exposure reservation, and the +paired quote/publication intent must find and reuse that same reservation rather than charging a +second copy. A mismatched set or root is a distinct request and is evaluated against the existing +reservation. Ratify is a **quote-enabling intent**, authorized like quote and never granted to +break-glass principals. Ecrecover deployments never use this intent. Policy parameters live in the middleware's deployment, never in the request; the state feeding its checks comes from its own reads, never from the caller. A compromised bot can neither relax @@ -305,14 +312,18 @@ them — but aggregate enforcement requires one small piece of **required state: reservation ledger**. Chain-truth reads cannot see a signature that was returned but never published, and the freshness ceiling bounds duration, not amount — without a ledger, repeated sign-and-withhold requests could multiply exposure far beyond the caps inside one window. Every -intent that makes exposure publishable — an approved **quote or ratify** — therefore records a -reservation (markets, exposure, root, expiry) at signing time; aggregate caps are enforced over -live offers **plus outstanding reservations**; a -reservation is released when its root is observed live (it then counts as live) or when its -freshness ceiling passes unpublished. The ceiling keeps the ledger tiny and self-expiring; -conditional writes serialize concurrent intents; the same ledger tracks the per-surface -signed-gas budgets, including the protected revoke reserve. The ledger is what makes the -bounded-loss claim hold. Transaction nonces stay +intent that makes exposure publishable — an approved **quote or ratify** — therefore records +per-offer reservations keyed by maker, derived root, and canonical offer identity (market, group, +side, cap, price, and expiry). Aggregate caps, crossed-book checks, and PnL checks are enforced +over live offers **plus every outstanding reserved offer**. Conditional creation makes a Setter +ratify followed by publication of the exact same root idempotent: the second intent reuses the +existing entries and cannot double-count or double-reserve them. Publication is tracked per leaf, +not merely per root: each reservation is released only when that exact offer is observed live (it +then counts as live) or its own freshness ceiling passes unpublished. Observing one leaf never +releases sibling leaves from the same root. The ceiling keeps the ledger tiny and self-expiring; +conditional writes serialize concurrent intents; the same ledger tracks the per-surface signed-gas +budgets, including the protected revoke reserve. The ledger is what makes the bounded-loss claim +hold. Transaction nonces stay caller-owned (see the intent contract), so the middleware never coordinates the account's transaction stream. @@ -526,9 +537,13 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no all three intent types, including boundary values — price exactly at a bound, expiry exactly at maturity or the freshness ceiling, aggregate exposure that overflows only in combination with live offers or outstanding reservations, sign-and-withhold sequences denied once reservations - exhaust the caps — through quote or ratify approvals alike, a crossing replacement denied - until its retired groups are observed invalidated and approved after, a ratify root that does - not match its offer set, a credit-reducing offer without `reduceOnly` denied, a caller-supplied + exhaust the caps — through quote or ratify approvals alike, two individually valid withheld + sets whose combination crosses denied on the second request, a Setter ratify followed by the + matching publication reusing one reservation, a partial publication releasing only observed + leaves while siblings stay reserved, a crossing replacement denied until its retired groups + are observed invalidated and approved after, a ratify root that does not match its offer set, + `setConsumed` with a foreign `onBehalf` or non-`MAX_OFFER_CAP` amount denied, a credit-reducing + offer without `reduceOnly` denied, a caller-supplied `continuousFeeCap` above the snapshot fee denied, a root revocation targeting Midnight instead of the ratifier denied, mixed-snapshot reads denied as incoherent, a routine signed-gas budget refusing the next publication while the revoke reserve still From c7a0a771074202f6cb691d3ca1b0081e04da365f Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:58:35 +0000 Subject: [PATCH 10/37] docs(quoter-bot): address signer middleware review Clarify Setter root revocation and publication timing, shared-cap accounting, independent emergency revoke budgets, and replacement fee headroom. --- ...08-12-quoter-bot-kms-signing-middleware.md | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 14581d15..926f032e 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -131,9 +131,12 @@ revocation only reduces exposure and is the always-available kill switch — con chain id, a **per-operation target/selector and calldata allowlist** (group consumption is exactly `setConsumed(group, MAX_OFFER_CAP, maker)` on the Midnight singleton, with the configured maker pinned as `onBehalf`; Ecrecover root cancellation is exactly `cancelRoot(maker, root)` on the -configured ratifier), zero native value, and fee/gas ceilings. This mirrors the constraint set -the quoter's in-process transaction assertions already pin, now enforced outside the bot; a -compromised revoke invoker cannot spend gas mutating another maker's groups. +configured ratifier; and Setter root cancellation is exactly +`setIsRootRatified(maker, root, false)` on the configured `SetterRatifier`), zero native value, +and fee/gas ceilings. Root cancellation is therefore available for both ratifier families rather +than forcing Setter deployments to cancel every group individually. This mirrors the constraint +set the quoter's in-process transaction assertions already pin, now enforced outside the bot; a +compromised revoke invoker cannot spend gas mutating another maker's groups or roots. Because a signed transaction commits to an account nonce, transaction-signing intents carry **caller-supplied nonce and fee fields** — liveness parameters, not policy: no nonce value can @@ -141,7 +144,12 @@ move funds, only strand or replace a transaction. The single-writer rule is unch the bot's serialized make/pending queue owns the nonce cursor in routine operation, and a break-glass revocation deliberately takes over the account's transaction stream during an incident — concurrent same-nonce signatures resolve on-chain as fee-bump replacements, and the -safety revocation is the one that must win. +safety revocation is the one that must win. That priority is enforced, not assumed: routine +transaction signing has lower maximum-fee and priority-fee ceilings, while break-glass revokes +reserve replacement ceilings above every routine ceiling by at least the policy's 12.5% +replacement bump plus one wei for both EIP-1559 fee fields. If an operator cannot provision that +headroom, break-glass must first take an ordered nonce handoff; the middleware must never sign a +routine transaction whose fee bid can strand an otherwise valid emergency replacement. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw @@ -150,14 +158,18 @@ budgets are **partitioned by invoke surface**: publication and ratification draw budget, while the revoke surface holds its **own protected reserve** — sized for several full-book cleanups — that routine signing can never draw down, so a compromised bot exhausting the routine budget cannot starve the kill switch. When the ledger is unavailable, quote/ratify -fail closed while revoke signing continues **uncharged but bounded**: per-transaction ceilings -still apply and the revoke surface is infrastructure-throttled (reserved concurrency), so -worst-case outage spend is rate-limited and the outage itself alerts. The final backstop is a +fail closed while revoke signing draws from a small **ledger-independent emergency-revoke +budget**: a durable, atomically consumed token/gas/nonce allowance on an independently operated +high-availability store, sized only for the configured full-book cleanup. Reserved concurrency +isolates revoke capacity from quote floods but is explicitly **not** a rate limit; it cannot bound +sequential signatures. If that independent budget cannot be checked atomically, revoke signing +fails closed and pages the operator rather than becoming unmetered. The final backstop is a **native-balance funding ceiling** — a new operational control this TIB requires, distinct from the existing `NATIVE_RESERVE_WEI` **minimum** readiness threshold: the operator funds the maker EOA between that minimum and a configured maximum, and monitoring alerts when the balance -exceeds the maximum. Gas grief can never exceed what is funded. Native-gas spend is thereby -capped and enters the bounded-loss arithmetic. +exceeds the maximum. Gas grief can never exceed the remaining routine budget plus the protected +emergency allowance, and never exceed what is funded. Native-gas spend is thereby capped and +enters the bounded-loss arithmetic. **Quote intents** carry an array of structured offers. There are **no caller-declared exclusions**: the prospective book is always the observed live book plus the proposed set, @@ -195,7 +207,8 @@ the proposed set, the maker's already-live offers from the middleware's own read still-outstanding signed-but-unpublished reservation (see Statefulness), never per-intent amounts alone; expiry ≤ market maturity and inside a **freshness ceiling** — a policy parameter capping offer lifetime from signing time, so a stockpiled signature dies quickly; offer start -not meaningfully before signing time; exact maker/receiver/callback/ratifier fields; owned +not meaningfully before the first validation/reservation time (with the Setter reuse rule below); +exact maker/receiver/callback/ratifier fields; owned group namespace; the per-side `reduceOnly` flag pinned by policy — the credit-reducing side must carry `reduceOnly: true`, so a signed sell fill can never turn inventory reduction into new debt; a `continuousFeeCap` the middleware derives from its own snapshot's current market fee — @@ -214,8 +227,13 @@ itself. Ratify and publication are therefore one ledgered flow: the first approv exact `(maker, root, offer set)` conditionally creates one per-offer exposure reservation, and the paired quote/publication intent must find and reuse that same reservation rather than charging a second copy. A mismatched set or root is a distinct request and is evaluated against the existing -reservation. Ratify is a **quote-enabling intent**, authorized like quote and never granted to -break-glass principals. Ecrecover deployments never use this intent. +reservation. The reservation persists its `validatedAt` snapshot time. For the paired Setter +publication, the offer `start` check is evaluated against the original reservation's validation +time, not the later publication-signing time after the ratification receipt; all other policy +checks are refreshed, and a deployment-configured maximum ratify-to-publish age bounds how long +that reuse remains valid. Once that age or the offer freshness ceiling expires, publication fails +closed and requires a newly validated root. Ratify is a **quote-enabling intent**, authorized like +quote and never granted to break-glass principals. Ecrecover deployments never use this intent. Policy parameters live in the middleware's deployment, never in the request; the state feeding its checks comes from its own reads, never from the caller. A compromised bot can neither relax @@ -314,16 +332,22 @@ published, and the freshness ceiling bounds duration, not amount — without a l sign-and-withhold requests could multiply exposure far beyond the caps inside one window. Every intent that makes exposure publishable — an approved **quote or ratify** — therefore records per-offer reservations keyed by maker, derived root, and canonical offer identity (market, group, -side, cap, price, and expiry). Aggregate caps, crossed-book checks, and PnL checks are enforced -over live offers **plus every outstanding reserved offer**. Conditional creation makes a Setter -ratify followed by publication of the exact same root idempotent: the second intent reuses the -existing entries and cannot double-count or double-reserve them. Publication is tracked per leaf, -not merely per root: each reservation is released only when that exact offer is observed live (it -then counts as live) or its own freshness ceiling passes unpublished. Observing one leaf never -releases sibling leaves from the same root. The ceiling keeps the ledger tiny and self-expiring; -conditional writes serialize concurrent intents; the same ledger tracks the per-surface signed-gas -budgets, including the protected revoke reserve. The ledger is what makes the bounded-loss claim -hold. Transaction nonces stay +side, cap, price, and expiry), while cap accounting records **one exposure amount per protocol +consumption domain** `(maker, market, group, side, cap kind/value)`. In `shared-rung` mode each rung +has its own group and cap; in `per-book` mode every leaf on one side repeats the same side-wide cap +and shared group, so that cap is counted once rather than multiplied by the rung count. Leaves +remain distinct for crossed-book, PnL, publication, and expiry tracking; only repeated shared-cap +exposure is deduplicated. Aggregate caps, crossed-book checks, and PnL checks are enforced over +live offers **plus every outstanding reserved offer** with those semantics. Conditional creation +makes a Setter ratify followed by publication of the exact same root idempotent: the second intent +reuses the existing entries and cannot double-count or double-reserve them. Publication is tracked +per leaf, not merely per root: each reservation is released only when that exact offer is observed +live (it then counts as live) or its own freshness ceiling passes unpublished. Observing one leaf +never releases sibling leaves from the same root. The ceiling keeps the ledger tiny and +self-expiring; conditional writes serialize concurrent intents. The reservation ledger tracks the +routine signed-gas budget and protected normal-operation revoke reserve; the separate +ledger-independent emergency allowance is consumed only during ledger outage. Together those +budgets make the bounded-loss claim hold. Transaction nonces stay caller-owned (see the intent contract), so the middleware never coordinates the account's transaction stream. @@ -337,7 +361,8 @@ transaction stream. | Revoke intent denied | Near-impossible by design; treat as misconfig, alert | | Concurrent tx signers (bot + break-glass) | Same-nonce fee-bump replacement resolves on-chain | | Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | -| Reservation ledger unavailable | Quote/ratify closed; revoke uncharged + throttled | +| Reservation ledger unavailable | Quote/ratify closed; revoke uses independent budget | +| Independent revoke budget unavailable | Revoke fails closed and pages operator | | KMS error | Typed failure; never assume a signature was produced | | Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | | Unknown intent type/version | Reject; no best-effort interpretation of payloads | From c75499fa7d8dbac7656d12b67c2e9c9efebeb54b Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:16:21 +0000 Subject: [PATCH 11/37] docs(quoter): tighten middleware loss budgets --- ...08-12-quoter-bot-kms-signing-middleware.md | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 926f032e..088539b5 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -154,13 +154,14 @@ routine transaction whose fee bid can strand an otherwise valid emergency replac Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw on **rolling signed-gas budgets** tracked in the reservation ledger (see Statefulness), and the -budgets are **partitioned by invoke surface**: publication and ratification draw on a routine -budget, while the revoke surface holds its **own protected reserve** — sized for several -full-book cleanups — that routine signing can never draw down, so a compromised bot exhausting -the routine budget cannot starve the kill switch. When the ledger is unavailable, quote/ratify -fail closed while revoke signing draws from a small **ledger-independent emergency-revoke -budget**: a durable, atomically consumed token/gas/nonce allowance on an independently operated -high-availability store, sized only for the configured full-book cleanup. Reserved concurrency +budgets are **keyed by caller principal and intent**: publication, ratification, and bot-originated +routine revokes draw on the routine budget, while break-glass revokes alone draw on a **protected +reserve** — sized for several full-book cleanups — that the bot principal can never draw down, so +a compromised bot cannot starve the kill switch with otherwise valid cancellations. When the +ledger is unavailable, quote/ratify and bot-originated routine revokes fail closed while the +break-glass principal draws from a small **ledger-independent emergency-revoke budget**: a durable, +atomically consumed token/gas/nonce allowance on an independently operated high-availability +store, sized only for the configured full-book cleanup. Reserved concurrency isolates revoke capacity from quote floods but is explicitly **not** a rate limit; it cannot bound sequential signatures. If that independent budget cannot be checked atomically, revoke signing fails closed and pages the operator rather than becoming unmetered. The final backstop is a @@ -264,7 +265,9 @@ The ports serve **every maker workflow, not only the ladder**: position bootstra auto-refill) signs the same transaction kinds through [`production-bootstrap.ts`](../../bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts), and in middleware mode a bootstrap top-up is simply a quote intent in its bootstrap group -namespace — same three properties, same field checks. No workflow retains a generic signer. +namespace — same three properties, same field checks. Because the no-PnL-drop property is strict, +middleware mode disables discounted bootstrap: bootstrap and auto-refill require a non-negative +premium and cannot use the existing negative-premium path. No workflow retains a generic signer. The ports are **transport-agnostic**: they express intents, not hosts. The Lambda invoker is one adapter behind them; plugging in a different middleware host tomorrow — an HTTP API, a Cloudflare @@ -285,7 +288,7 @@ logic, and any alternative host must preserve the trust split: the middleware al `lambda:InvokeFunction` on this function's intent surfaces — the bot loses `kms:Sign` entirely. The Lambda's execution role is the only principal with `kms:Sign` on the maker key, plus the outbound reads its checks need. Creating that execution role, the invoke-only credentials, and - the CloudTrail data-event selector for the function (see Observability) is part of the + the CloudTrail data-event selectors for all three intent functions (see Observability) are part of the deliverable. - **Caller-to-intent scoping**: principals are authorized per intent type, not merely per function. Each of the three intents is its **own Lambda function** — one shared container @@ -344,28 +347,28 @@ reuses the existing entries and cannot double-count or double-reserve them. Publ per leaf, not merely per root: each reservation is released only when that exact offer is observed live (it then counts as live) or its own freshness ceiling passes unpublished. Observing one leaf never releases sibling leaves from the same root. The ceiling keeps the ledger tiny and -self-expiring; conditional writes serialize concurrent intents. The reservation ledger tracks the -routine signed-gas budget and protected normal-operation revoke reserve; the separate -ledger-independent emergency allowance is consumed only during ledger outage. Together those +self-expiring; conditional writes serialize concurrent intents. The reservation ledger tracks the routine signed-gas budget and the break-glass-principal-only +protected revoke reserve; the separate ledger-independent emergency allowance is consumed only +by that break-glass principal during ledger outage. Together those budgets make the bounded-loss claim hold. Transaction nonces stay caller-owned (see the intent contract), so the middleware never coordinates the account's transaction stream. ### 7. Failure posture -| Failure | Required behavior | -| ----------------------------------------- | ---------------------------------------------------- | -| Invocation fails (throttle, error, limit) | Halt quoting (fail closed) and retry; offers stand | -| Cold start latency | Tolerated; the hourly-ish cadence absorbs it | -| Quote intent denied | Typed rejection, nothing signed; alert if persistent | -| Revoke intent denied | Near-impossible by design; treat as misconfig, alert | -| Concurrent tx signers (bot + break-glass) | Same-nonce fee-bump replacement resolves on-chain | -| Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | -| Reservation ledger unavailable | Quote/ratify closed; revoke uses independent budget | -| Independent revoke budget unavailable | Revoke fails closed and pages operator | -| KMS error | Typed failure; never assume a signature was produced | -| Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | -| Unknown intent type/version | Reject; no best-effort interpretation of payloads | +| Failure | Required behavior | +| ----------------------------------------- | ----------------------------------------------------------------------- | +| Invocation fails (throttle, error, limit) | Halt quoting (fail closed) and retry; offers stand | +| Cold start latency | Tolerated; the hourly-ish cadence absorbs it | +| Quote intent denied | Typed rejection, nothing signed; alert if persistent | +| Revoke intent denied | Near-impossible by design; treat as misconfig, alert | +| Concurrent tx signers (bot + break-glass) | Same-nonce fee-bump replacement resolves on-chain | +| Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | +| Reservation ledger unavailable | Quote/ratify/routine revoke closed; break-glass uses independent budget | +| Independent revoke budget unavailable | Revoke fails closed and pages operator | +| KMS error | Typed failure; never assume a signature was produced | +| Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | +| Unknown intent type/version | Reject; no best-effort interpretation of payloads | ## Considered Alternatives @@ -447,9 +450,10 @@ host itself; it strengthens rather than changes this design. remaining way to get a bad set past those two checks (open question 7). Reads for one intent are pinned to a single deterministic snapshot; a mixed-block view is a denial, not an input. - A small managed store with conditional writes (e.g. DynamoDB) is available to the Lambda for - the reservation ledger and the per-surface signed-gas budgets. It holds no secrets; its - unavailability fails quoting closed while revocation stays served uncharged under - infrastructure throttling. + the reservation ledger and the principal-and-intent-keyed signed-gas budgets. It holds no + secrets; its unavailability fails quote/ratify and bot-originated routine revokes closed, while + break-glass revocation must atomically consume the independently stored emergency budget or fail + closed and page the operator. - The maker EOA's native balance is operationally bounded: funded above the `NATIVE_RESERVE_WEI` minimum and below the new configured funding ceiling, with an alert on breach. The gas-grief hard cap is this operational control, not a protocol guarantee. @@ -497,8 +501,8 @@ host itself; it strengthens rather than changes this design. side is an incident signal. Lambda `Invoke` is a CloudTrail **data event and is not logged by default** ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); - enabling the data-event selector for this function is an explicit v0 deliverable — without it, - the invoke side of this audit trail silently does not exist. + enabling data-event selectors for all three intent function ARNs is an explicit v0 deliverable — + without complete coverage, the invoke side of this audit trail silently omits intent surfaces. - Alerting on denials, invocation errors/throttles, KMS errors, and independent-read failures. Bot-side `make.rejected` events extend with middleware-denial reasons; invocation-failure halts surface through the existing failure events. From 91da93b261a019ba09f734850af2fbc5e0765a57 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:33:04 +0000 Subject: [PATCH 12/37] docs(quoter): address signing middleware review Clarify Setter fee-cap reuse and nonce safety, pin v0 exposure caps to assets, and define break-glass deny and durable outage budgeting. --- ...08-12-quoter-bot-kms-signing-middleware.md | 71 ++++++++++++------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 088539b5..f69eab6a 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -139,17 +139,18 @@ set the quoter's in-process transaction assertions already pin, now enforced out compromised revoke invoker cannot spend gas mutating another maker's groups or roots. Because a signed transaction commits to an account nonce, transaction-signing intents carry -**caller-supplied nonce and fee fields** — liveness parameters, not policy: no nonce value can -move funds, only strand or replace a transaction. The single-writer rule is unchanged from today: -the bot's serialized make/pending queue owns the nonce cursor in routine operation, and a -break-glass revocation deliberately takes over the account's transaction stream during an -incident — concurrent same-nonce signatures resolve on-chain as fee-bump replacements, and the -safety revocation is the one that must win. That priority is enforced, not assumed: routine -transaction signing has lower maximum-fee and priority-fee ceilings, while break-glass revokes -reserve replacement ceilings above every routine ceiling by at least the policy's 12.5% -replacement bump plus one wei for both EIP-1559 fee fields. If an operator cannot provision that -headroom, break-glass must first take an ordered nonce handoff; the middleware must never sign a -routine transaction whose fee bid can strand an otherwise valid emergency replacement. +**caller-supplied fee fields** as liveness parameters, but nonce ordering is policy-relevant. In +routine operation the middleware reads the maker's current pending nonce independently and signs +only that nonce; it never signs a stockpile of future-nonce transactions. The bot's serialized +make/pending queue remains the single routine writer. A break-glass revocation deliberately takes +over the account's transaction stream during an incident — concurrent same-nonce signatures +resolve on-chain as fee-bump replacements, and the safety revocation is the one that must win. +That priority is enforced, not assumed: routine transaction signing has lower maximum-fee and +priority-fee ceilings, while break-glass revokes reserve replacement ceilings above every routine +ceiling by at least the policy's 12.5% replacement bump plus one wei for both EIP-1559 fee fields. +If an operator cannot provision that headroom, break-glass must first take an ordered nonce +handoff; the middleware must never sign a routine transaction whose fee bid can strand an +otherwise valid emergency replacement. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw @@ -214,7 +215,9 @@ group namespace; the per-side `reduceOnly` flag pinned by policy — the credit- carry `reduceOnly: true`, so a signed sell fill can never turn inventory reduction into new debt; a `continuousFeeCap` the middleware derives from its own snapshot's current market fee — never unbounded or caller-chosen, so a later fee raise cannot make a fill accept a worse fee -than the PnL check priced; and cap semantics (exactly one of `maxUnits`/`maxAssets` non-zero). +than the PnL check priced; and asset-denominated cap semantics: v0 accepts only `maxAssets`, which +must be non-zero, and requires `maxUnits` to be zero. This matches the current builders and makes +every reservation directly comparable to the per-market and total asset exposure budgets. **Ratify intents** exist for Setter-ratifier deployments, whose ladder flow must send `setIsRootRatified` before a quote tree becomes takeable. A ratify intent carries the same @@ -228,13 +231,17 @@ itself. Ratify and publication are therefore one ledgered flow: the first approv exact `(maker, root, offer set)` conditionally creates one per-offer exposure reservation, and the paired quote/publication intent must find and reuse that same reservation rather than charging a second copy. A mismatched set or root is a distinct request and is evaluated against the existing -reservation. The reservation persists its `validatedAt` snapshot time. For the paired Setter -publication, the offer `start` check is evaluated against the original reservation's validation -time, not the later publication-signing time after the ratification receipt; all other policy -checks are refreshed, and a deployment-configured maximum ratify-to-publish age bounds how long -that reuse remains valid. Once that age or the offer freshness ceiling expires, publication fails -closed and requires a newly validated root. Ratify is a **quote-enabling intent**, authorized like -quote and never granted to break-glass principals. Ecrecover deployments never use this intent. +reservation. The reservation persists its `validatedAt` snapshot time, the exact validated offer +fields, and persists the validated `continuousFeeCap` derived by the middleware. For the paired +Setter publication, the offer `start` check is evaluated against the original reservation's +validation time, not the later publication-signing time after the ratification receipt, and publication +reuses the reserved fee cap rather than requiring it to equal a newly derived cap. The middleware +refreshes the market-fee and PnL reads and verifies that the reserved cap remains safe under the +later snapshot; it fails closed if it does not. Every other policy check is refreshed, and a +deployment-configured maximum ratify-to-publish age bounds how long that reuse remains valid. Once +that age or the offer freshness ceiling expires, publication fails closed and requires a newly +validated root. Ratify is a **quote-enabling intent**, authorized like quote and never granted to +break-glass principals. Ecrecover deployments never use this intent. Policy parameters live in the middleware's deployment, never in the request; the state feeding its checks comes from its own reads, never from the caller. A compromised bot can neither relax @@ -324,6 +331,14 @@ logic, and any alternative host must preserve the trust split: the middleware al - **Break-glass revoke is just another IAM principal** granted the revoke invoke surface: an operator can invoke the revoke intent directly with their own credentials, with no bot in the path — and that surface cannot produce quote signatures. +- Entering break-glass mode first atomically enables an emergency deny in middleware policy and + disables the routine quote and ratify invoke grants before cleanup starts. Routine principals + cannot obtain fresh quote-enabling signatures until an independently authorized operator clears + the deny after verifying cleanup; revocation remains available throughout. +- Setter cleanup treats irreversible group cancellations as authoritative. The runbook drains or + replaces every already-signed ratification transaction and consumes every affected offer group; + `setIsRootRatified(..., false)` is defense in depth, not the sole kill switch, because an older + signed `true` transaction could otherwise execute later and restore the mutable root flag. ### 6. Statefulness @@ -347,12 +362,12 @@ reuses the existing entries and cannot double-count or double-reserve them. Publ per leaf, not merely per root: each reservation is released only when that exact offer is observed live (it then counts as live) or its own freshness ceiling passes unpublished. Observing one leaf never releases sibling leaves from the same root. The ceiling keeps the ledger tiny and -self-expiring; conditional writes serialize concurrent intents. The reservation ledger tracks the routine signed-gas budget and the break-glass-principal-only -protected revoke reserve; the separate ledger-independent emergency allowance is consumed only -by that break-glass principal during ledger outage. Together those -budgets make the bounded-loss claim hold. Transaction nonces stay -caller-owned (see the intent contract), so the middleware never coordinates the account's -transaction stream. +self-expiring; conditional writes serialize concurrent intents. The reservation ledger tracks the +routine signed-gas budget and the break-glass-principal-only protected revoke reserve; the separate +ledger-independent emergency allowance is consumed only by that break-glass principal during a +ledger outage. Together those budgets make the bounded-loss claim hold. The middleware validates +routine transaction nonces against the current pending nonce but does not allocate or advance the +account's nonce cursor; the routine single writer and break-glass runbook coordinate the stream. ### 7. Failure posture @@ -629,8 +644,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no audited. 6. The reservation ledger's concrete store and consistency design — DynamoDB conditional writes are the default candidate — including release on observed publication/invalidation, expiry - eviction, per-surface budget partitioning details, and the reserved-concurrency throttle that - bounds uncharged revoke signing during an outage. + eviction, per-surface budget partitioning details, and the independently operated, + high-availability independent emergency-budget store used during a ledger outage. Every outage + revoke must atomically consume that durable allowance or fail closed; reserved concurrency is + only availability isolation and never substitutes for budget accounting. 7. Lambda networking/egress design for its independent RPC and Morpho API/Mempool reads — and the decision posture when those providers disagree with the bot's view of the book. From 675889c82035af23d60cd71af6112ca13656bc23 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:56:08 +0000 Subject: [PATCH 13/37] docs(quoter): address follow-up middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index f69eab6a..d69a98b8 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -138,6 +138,15 @@ than forcing Setter deployments to cancel every group individually. This mirrors set the quoter's in-process transaction assertions already pin, now enforced outside the bot; a compromised revoke invoker cannot spend gas mutating another maker's groups or roots. +Maker-wide cleanup and startup cleanup keep the existing batch behavior exposed by +`OfferInvalidationPort.invalidateBatch`: the revoke surface may encode one Midnight `multicall`, +but only from a structured list of group-consumption intents. The middleware recursively validates +every inner call as an exact zero-value `setConsumed(group, MAX_OFFER_CAP, maker)` against the +configured Midnight singleton and maker, rejects nested multicalls and every other selector or +target, and then encodes the outer multicall itself. An empty batch is rejected. This gives the +current all-groups cleanup path one policy-checked transaction without turning `multicall` into an +arbitrary-call escape hatch. + Because a signed transaction commits to an account nonce, transaction-signing intents carry **caller-supplied fee fields** as liveness parameters, but nonce ordering is policy-relevant. In routine operation the middleware reads the maker's current pending nonce independently and signs @@ -219,6 +228,14 @@ than the PnL check priced; and asset-denominated cap semantics: v0 accepts only must be non-zero, and requires `maxUnits` to be zero. This matches the current builders and makes every reservation directly comparable to the per-market and total asset exposure budgets. +Middleware mode also changes offer construction: the ladder and bootstrap/auto-refill builders +persist `expiry = min(market maturity, signedAt + freshness ceiling)` in the structured intent and +in the exact offer payload returned for broadcast. The middleware derives that bound from its own +clock and deployment policy and rejects caller-supplied expiries outside it; it never silently +signs a different offer than the one returned. This builder change is part of the bot-side seam, +so long-dated markets continue to quote instead of having their maturity-dated offers denied by +the middleware. + **Ratify intents** exist for Setter-ratifier deployments, whose ladder flow must send `setIsRootRatified` before a quote tree becomes takeable. A ratify intent carries the same structured offer set as a quote intent; the middleware re-validates it in full, re-derives the @@ -488,6 +505,11 @@ host itself; it strengthens rather than changes this design. ([Lambda container images](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html)). - The Lambda's independent read surfaces: resilient RPC access (fallback and/or quorum across providers) and the Morpho API/Mempool for live offers, positions, and chain state. +- A versioned Morpho API/Mempool response that exposes authoritative indexed-block number/hash + metadata for every live-offer and position read used by policy. The current generated + `ListTakeableOfferResponse` exposes only `cursor` and `data`, so adding and consuming this + metadata is a blocking v0 deliverable; middleware quote/ratify signing must remain disabled + until an integration test proves all policy reads can be pinned to one indexed block. - A small managed state store for the reservation ledger and signed-gas budget (e.g. DynamoDB with conditional writes). - `@morpho-org/midnight-sdk` offer-tree EIP-712 hashing for canonical encoding inside the Lambda. @@ -590,6 +612,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no offer without `reduceOnly` denied, a caller-supplied `continuousFeeCap` above the snapshot fee denied, a root revocation targeting Midnight instead of the ratifier denied, mixed-snapshot reads denied as incoherent, + missing or inconsistent API indexed-block metadata denied, ladder and bootstrap offers on a + long-dated market persisted with `min(maturity, signedAt + freshness)` expiry, + maker-wide cleanup encoded as one policy-checked multicall whose inner `setConsumed` calls all + pass the same target/maker/cap checks, with nested/empty/foreign-selector batches denied, a routine signed-gas budget refusing the next publication while the revoke reserve still signs, both/neither of `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective set that crosses only in combination with live offers. From 05c39b7d578ea0129856784ed79e7d27bf60d28d Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:20:21 +0000 Subject: [PATCH 14/37] docs(quoter-bot): address signing middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 95 ++++++++++++------- 1 file changed, 63 insertions(+), 32 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index d69a98b8..7a74bfc1 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -171,7 +171,12 @@ a compromised bot cannot starve the kill switch with otherwise valid cancellatio ledger is unavailable, quote/ratify and bot-originated routine revokes fail closed while the break-glass principal draws from a small **ledger-independent emergency-revoke budget**: a durable, atomically consumed token/gas/nonce allowance on an independently operated high-availability -store, sized only for the configured full-book cleanup. Reserved concurrency +store, sized only for the configured full-book cleanup. The same independent control plane keeps a +**ledger-independent revocation inventory**: an append-only, read-replicated catalog of every root +and group whose signature could make exposure publishable. Catalog persistence is a write-before- +sign condition for quote and ratify; if both the primary ledger and catalog cannot durably record +an entry, signing fails closed. Break-glass therefore retains the targets needed to cancel hidden, +signed-but-unpublished exposure even when the primary reservation ledger cannot be read. Reserved concurrency isolates revoke capacity from quote floods but is explicitly **not** a rate limit; it cannot bound sequential signatures. If that independent budget cannot be checked atomically, revoke signing fails closed and pages the operator rather than becoming unmetered. The final backstop is a @@ -213,10 +218,13 @@ The set is approved only when **three properties** all hold: are open questions; the property itself is a decided policy requirement. Beneath these three headline properties, **field-level validation** on every offer: market -allowlist; per-market and total exposure caps, enforced against **aggregate signed exposure** — -the proposed set, the maker's already-live offers from the middleware's own reads, and every -still-outstanding signed-but-unpublished reservation (see Statefulness), never per-intent -amounts alone; expiry ≤ market maturity and inside a **freshness ceiling** — a policy parameter +allowlist; per-market and total exposure caps, enforced against the maker's **current filled positions** +plus **aggregate signed exposure** — the proposed set, the maker's already-live offers from the +middleware's own reads, and every still-outstanding signed-but-unpublished reservation (see +Statefulness), never per-intent amounts alone. Position and offer state come from the same pinned, +independent snapshot, and cap headroom is the configured budget minus filled position exposure, +live offers, and reservations; a fill therefore consumes rather than restores signing room. Expiry +≤ market maturity and inside a **freshness ceiling** — a policy parameter capping offer lifetime from signing time, so a stockpiled signature dies quickly; offer start not meaningfully before the first validation/reservation time (with the Setter reuse rule below); exact maker/receiver/callback/ratifier fields; owned @@ -244,21 +252,22 @@ derived root — under the same chain/value/fee pins as revocations, targeting t ratifier. The middleware never needs to remember which roots it produced: it recomputes them. Because a ratified root is publishable by **any funded sender** — the Mempool log contract accepts the encoded payload from any account — a signed ratification is publishable exposure in -itself. Ratify and publication are therefore one ledgered flow: the first approved intent for an -exact `(maker, root, offer set)` conditionally creates one per-offer exposure reservation, and the -paired quote/publication intent must find and reuse that same reservation rather than charging a -second copy. A mismatched set or root is a distinct request and is evaluated against the existing -reservation. The reservation persists its `validatedAt` snapshot time, the exact validated offer -fields, and persists the validated `continuousFeeCap` derived by the middleware. For the paired -Setter publication, the offer `start` check is evaluated against the original reservation's -validation time, not the later publication-signing time after the ratification receipt, and publication -reuses the reserved fee cap rather than requiring it to equal a newly derived cap. The middleware -refreshes the market-fee and PnL reads and verifies that the reserved cap remains safe under the -later snapshot; it fails closed if it does not. Every other policy check is refreshed, and a -deployment-configured maximum ratify-to-publish age bounds how long that reuse remains valid. Once -that age or the offer freshness ceiling expires, publication fails closed and requires a newly -validated root. Ratify is a **quote-enabling intent**, authorized like quote and never granted to -break-glass principals. Ecrecover deployments never use this intent. +itself. Ratify approval is therefore the **final publication authorization**, not the first half of +a safety decision that can be revoked by a later middleware check. Before signing the ratification, +the middleware performs every quote check against one pinned snapshot, derives the exact root and +publication payload, fixes the offer expiry and `continuousFeeCap`, and atomically reserves the +exposure. The returned publication payload may be broadcast by any sender without another policy +decision; no security claim depends on that sender returning for a paired publication intent. + +Ratify and publication still share one ledgered identity: the approved `(maker, root, offer set)` +conditionally creates one per-offer exposure reservation, and later observation of that exact +publication reuses rather than double-charges it. A mismatched set or root is a distinct request and +is evaluated against the existing reservation. After ratification, the only bounds are those encoded +in the authorized artifacts and chain state: the offer freshness expiry, the fixed fee cap, group +consumption, or root cancellation. A refreshed PnL/market-fee check or a middleware-only +ratify-to-publish timer cannot fail closed once a third party can publish, so neither is presented as +a post-ratify control. Ratify is a **quote-enabling intent**, authorized like quote and never granted +to break-glass principals. Ecrecover deployments never use this intent. Policy parameters live in the middleware's deployment, never in the request; the state feeding its checks comes from its own reads, never from the caller. A compromised bot can neither relax @@ -348,10 +357,18 @@ logic, and any alternative host must preserve the trust split: the middleware al - **Break-glass revoke is just another IAM principal** granted the revoke invoke surface: an operator can invoke the revoke intent directly with their own credentials, with no bot in the path — and that surface cannot produce quote signatures. -- Entering break-glass mode first atomically enables an emergency deny in middleware policy and - disables the routine quote and ratify invoke grants before cleanup starts. Routine principals - cannot obtain fresh quote-enabling signatures until an independently authorized operator clears - the deny after verifying cleanup; revocation remains available throughout. +- Entering break-glass mode first atomically increments a durable **deny generation** in middleware + policy and disables the routine quote and ratify invoke grants before cleanup starts. Every + quote/ratify handler captures that generation on admission and acquires a generation-scoped + signing lease in the same transaction that reserves aggregate capacity. The handler checks the + lease immediately before KMS signing and releases it only after the result is durably cataloged. + Break-glass transition denies new leases and waits for every older-generation lease to drain (or + be conclusively failed and its reservation released) before cleanup starts. An invocation admitted + before emergency deny can therefore either finish as known revocation inventory before cleanup or + produce no signature; it cannot return an untracked fresh signature during cleanup. If lease state + cannot be checked or drained, cleanup does not claim containment and pages the operator. Routine + principals cannot obtain fresh quote-enabling signatures until an independently authorized + operator clears the deny after verifying cleanup; revocation remains available throughout. - Setter cleanup treats irreversible group cancellations as authoritative. The runbook drains or replaces every already-signed ratification transaction and consumes every affected offer group; `setIsRootRatified(..., false)` is defense in depth, not the sole kill switch, because an older @@ -372,14 +389,28 @@ consumption domain** `(maker, market, group, side, cap kind/value)`. In `shared- has its own group and cap; in `per-book` mode every leaf on one side repeats the same side-wide cap and shared group, so that cap is counted once rather than multiplied by the rung count. Leaves remain distinct for crossed-book, PnL, publication, and expiry tracking; only repeated shared-cap -exposure is deduplicated. Aggregate caps, crossed-book checks, and PnL checks are enforced over -live offers **plus every outstanding reserved offer** with those semantics. Conditional creation -makes a Setter ratify followed by publication of the exact same root idempotent: the second intent -reuses the existing entries and cannot double-count or double-reserve them. Publication is tracked -per leaf, not merely per root: each reservation is released only when that exact offer is observed -live (it then counts as live) or its own freshness ceiling passes unpublished. Observing one leaf -never releases sibling leaves from the same root. The ceiling keeps the ledger tiny and -self-expiring; conditional writes serialize concurrent intents. The reservation ledger tracks the +exposure is deduplicated. Aggregate caps, crossed-book checks, and PnL checks are enforced over current filled positions, +live offers, and **every outstanding reserved offer** with those semantics. Reservation creation +transactionally updates **atomic aggregate counters** for every affected `(maker, market)` cap, the +maker-wide cap, and the applicable signed-gas budget. Each counter update conditionally requires +enough remaining headroom, and the counter updates, per-offer records, deny-generation fence, and +idempotency marker commit as one transaction. Concurrent intents for different roots therefore +serialize on shared counter versions instead of both spending the same observed headroom; a failed +condition causes a fresh snapshot and full re-evaluation, never a partial reservation. Conditional +creation makes an exact Setter ratify/publication observation idempotent and cannot double-count or +double-reserve it. + +Publication is tracked per leaf, not merely per root. A reservation moves from reserved to live +when that exact offer is observed and is released on its own freshness expiry. Observed group +consumption and Ecrecover root cancellation are also **root cancellation as release conditions** for +every affected reserved leaf, including signed-but-unpublished leaves that can no longer become +takeable. Setter `false` releases only after the older signed `true` transaction has been replaced or +otherwise made unusable and that outcome is final; irreversible group consumption remains the +authoritative immediate Setter release signal. Releases decrement the same aggregate counters +transactionally and carry an idempotent terminal marker, so observation, cancellation, and expiry +races cannot free capacity twice. Observing one live leaf never releases a still-publishable sibling +merely because they share a root. The ceiling and terminal cleanup keep the ledger tiny and +self-expiring. The reservation ledger tracks the routine signed-gas budget and the break-glass-principal-only protected revoke reserve; the separate ledger-independent emergency allowance is consumed only by that break-glass principal during a ledger outage. Together those budgets make the bounded-loss claim hold. The middleware validates From 185258241c35742ae958d823da42fd2168c1e5c3 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:44:18 +0000 Subject: [PATCH 15/37] docs(quoter-bot): authenticate revoke budget surfaces Split routine and break-glass revoke into separate IAM-authorized Lambda functions so caller-controlled payload fields cannot select the protected emergency budget. --- ...08-12-quoter-bot-kms-signing-middleware.md | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 7a74bfc1..5db73baf 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -163,13 +163,17 @@ otherwise valid emergency replacement. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw -on **rolling signed-gas budgets** tracked in the reservation ledger (see Statefulness), and the -budgets are **keyed by caller principal and intent**: publication, ratification, and bot-originated -routine revokes draw on the routine budget, while break-glass revokes alone draw on a **protected -reserve** — sized for several full-book cleanups — that the bot principal can never draw down, so -a compromised bot cannot starve the kill switch with otherwise valid cancellations. When the -ledger is unavailable, quote/ratify and bot-originated routine revokes fail closed while the -break-glass principal draws from a small **ledger-independent emergency-revoke budget**: a durable, +on **rolling signed-gas budgets** tracked in the reservation ledger (see Statefulness). The budget +class comes from the authenticated Lambda invoke surface, never from a caller-supplied principal, +intent field, or `ClientContext`: publication, ratification, and the bot-only `routine-revoke` +function draw on the routine budget, while the operator-only `break-glass-revoke` function alone +draws on a **protected reserve** — sized for several full-book cleanups — that the bot role has no +IAM permission to invoke or draw down. Both revoke functions use the same strict revoke validator +from the shared image, but their function ARNs, IAM grants, reserved concurrency, and server-side +budget classes are distinct. A compromised bot therefore cannot impersonate break-glass in its +payload or starve the kill switch with otherwise valid cancellations. When the ledger is +unavailable, quote/ratify and bot-originated routine revokes fail closed while the break-glass +surface draws from a small **ledger-independent emergency-revoke budget**: a durable, atomically consumed token/gas/nonce allowance on an independently operated high-availability store, sized only for the configured full-book cleanup. The same independent control plane keeps a **ledger-independent revocation inventory**: an append-only, read-replicated catalog of every root @@ -315,29 +319,33 @@ an HTTP API, a Cloudflare Worker — replaces the handler and the bot-side adapt logic, and any alternative host must preserve the trust split: the middleware alone holds `kms:Sign`, and callers hold nothing but the right to invoke it. -- The middleware is a set of **AWS Lambda functions — one per intent surface, built from one - shared container image** — invoked by the bot through the AWS SDK (`lambda:InvokeFunction`). +- The middleware is a set of **four AWS Lambda functions — quote, ratify, routine revoke, and + break-glass revoke — built from one shared container image** and invoked through the AWS SDK + (`lambda:InvokeFunction`). Routine and break-glass revoke deliberately have separate authenticated + invoke surfaces even though they enforce the same structured revoke intent. - **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are `lambda:InvokeFunction` on this function's intent surfaces — the bot loses `kms:Sign` entirely. The Lambda's execution role is the only principal with `kms:Sign` on the maker key, plus the outbound reads its checks need. Creating that execution role, the invoke-only credentials, and - the CloudTrail data-event selectors for all three intent functions (see Observability) are part of the + the CloudTrail data-event selectors for all four functions (see Observability) are part of the deliverable. -- **Caller-to-intent scoping**: principals are authorized per intent type, not merely per - function. Each of the three intents is its **own Lambda function** — one shared container - image, three deployments. Aliases are not enough: reserved concurrency is function-scoped, so - only separate functions give the revoke surface its own concurrency pool that a quote/ratify - flood cannot exhaust. IAM grants scope each principal to the functions it may invoke, and each - handler independently enforces the intent type it serves. Ratify is quote-enabling and is - granted like quote. Break-glass principals receive the revoke function only: leaked - break-glass credentials must yield revocations, never signed quotes or ratifications. +- **Caller-to-surface scoping**: principals are authorized per function ARN. The three structured + intent types map to **four Lambda functions** — one shared container image, four deployments — + because routine and break-glass revoke must be distinguishable by an authenticated AWS boundary, + not by untrusted payload data. Aliases are not enough: reserved concurrency is function-scoped, + and distinct functions also let IAM deny the bot access to the protected reserve. IAM grants the + bot quote, ratify, and routine-revoke only; break-glass principals receive break-glass-revoke + only. Each handler pins its intent type and budget class in deployment configuration before + calling the shared validator. It never reads a claimed principal or budget class from the intent + payload or `ClientContext`. Leaked break-glass credentials must yield revocations, never signed + quotes or ratifications, while leaked bot credentials can never consume emergency capacity. - Authentication is therefore **IAM/SigV4** — no self-managed ingress, tokens, or mTLS. The in-policy guarantee still does not depend on caller identity — any invoker only ever obtains - in-policy signatures — while the caller-to-intent scoping above decides _which_ in-policy + in-policy signatures — while the caller-to-surface scoping above decides _which_ in-policy intents a given principal may submit, and invoke scoping keeps revoke-griefing/DoS hard and the audit trail attributable. - The middleware's **code lives in this monorepo** and deploys as one **Docker container image** - (ECR-hosted) instantiated as the three intent functions, with its own Dockerfile, like the + (ECR-hosted) instantiated as the four functions, with its own Dockerfile, like the bots. It is not a bot — not a long-running program — so it does not live under `/bots/`; the proposed workspace home is a new top-level `services/` directory, e.g. `services/quoter-signer`. Final naming and location @@ -354,9 +362,10 @@ logic, and any alternative host must preserve the trust split: the middleware al fails; the bot halts publication and retries rather than degrading to any local signing path. - The **revoke path must be the most available operation** — it is the safety action under incident conditions. -- **Break-glass revoke is just another IAM principal** granted the revoke invoke surface: an +- **Break-glass revoke uses its own IAM-authorized function**: an operator can invoke the revoke intent directly with their own credentials, with no bot in the - path — and that surface cannot produce quote signatures. + path — and that surface cannot produce quote signatures. The bot cannot invoke this function; + its separate routine-revoke function cannot select the protected budget in request data. - Entering break-glass mode first atomically increments a durable **deny generation** in middleware policy and disables the routine quote and ratify invoke grants before cleanup starts. Every quote/ratify handler captures that generation on admission and acquires a generation-scoped @@ -499,9 +508,10 @@ host itself; it strengthens rather than changes this design. ## Assumptions & Constraints - IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly - the intent surfaces it needs and nothing else; the Lambda's execution role is the sole - `kms:Sign` principal on the maker key; break-glass operators hold revoke-surface invoke grants - only. + quote, ratify, and routine-revoke and nothing else; the Lambda execution roles are the sole + `kms:Sign` principals on the maker key; break-glass operators hold only the separate + break-glass-revoke invoke grant. Policy selects the budget from that fixed function deployment, + never from caller-controlled request data. - Every signed payload class is fully describable as structured intents and canonically encodable inside the Lambda (SDK EIP-712 offer-tree hashing and Mempool payload encoding; viem transaction serialization). @@ -513,7 +523,7 @@ host itself; it strengthens rather than changes this design. remaining way to get a bad set past those two checks (open question 7). Reads for one intent are pinned to a single deterministic snapshot; a mixed-block view is a denial, not an input. - A small managed store with conditional writes (e.g. DynamoDB) is available to the Lambda for - the reservation ledger and the principal-and-intent-keyed signed-gas budgets. It holds no + the reservation ledger and invoke-surface-and-intent-keyed signed-gas budgets. It holds no secrets; its unavailability fails quote/ratify and bot-originated routine revokes closed, while break-glass revocation must atomically consume the independently stored emergency budget or fail closed and page the operator. @@ -569,7 +579,7 @@ host itself; it strengthens rather than changes this design. side is an incident signal. Lambda `Invoke` is a CloudTrail **data event and is not logged by default** ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); - enabling data-event selectors for all three intent function ARNs is an explicit v0 deliverable — + enabling data-event selectors for all four function ARNs is an explicit v0 deliverable — without complete coverage, the invoke side of this audit trail silently omits intent surfaces. - Alerting on denials, invocation errors/throttles, KMS errors, and independent-read failures. Bot-side `make.rejected` events extend with middleware-denial reasons; invocation-failure halts @@ -663,8 +673,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no missing or invalid policy configuration refuses to serve. - **Integration:** bot plus deployed Lambda against a KMS test key — quote publish, revoke, denial propagation into `make.rejected`, the invocation-failure halt, a break-glass revoke - invoked by a second IAM principal, and the funding-ceiling alert when the maker's native - balance exceeds its configured maximum. + invoked by a second IAM principal, an attempted bot invocation of `break-glass-revoke` denied by + IAM, a routine-revoke payload that claims break-glass identity still charged only to the routine + budget, and the funding-ceiling alert when the maker's native balance exceeds its configured + maximum. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` after the grant moves, that each role can invoke only its granted intent surfaces, and that a break-glass principal is denied on the quote and ratify surfaces. A denied call is part of From b558e22a1b2871328c104995098985658d786866 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:02:33 +0000 Subject: [PATCH 16/37] docs(quoter-bot): address signer middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 94 +++++++++++++------ 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 5db73baf..ea8b1012 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -151,15 +151,23 @@ Because a signed transaction commits to an account nonce, transaction-signing in **caller-supplied fee fields** as liveness parameters, but nonce ordering is policy-relevant. In routine operation the middleware reads the maker's current pending nonce independently and signs only that nonce; it never signs a stockpile of future-nonce transactions. The bot's serialized -make/pending queue remains the single routine writer. A break-glass revocation deliberately takes -over the account's transaction stream during an incident — concurrent same-nonce signatures -resolve on-chain as fee-bump replacements, and the safety revocation is the one that must win. -That priority is enforced, not assumed: routine transaction signing has lower maximum-fee and -priority-fee ceilings, while break-glass revokes reserve replacement ceilings above every routine -ceiling by at least the policy's 12.5% replacement bump plus one wei for both EIP-1559 fee fields. -If an operator cannot provision that headroom, break-glass must first take an ordered nonce -handoff; the middleware must never sign a routine transaction whose fee bid can strand an -otherwise valid emergency replacement. +make/pending queue remains the single routine writer, and the middleware records every returned +routine transaction's nonce, hash, intent kind, and fee fields before releasing it. + +A break-glass revocation deliberately takes over the account's transaction stream during an +incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It first +selects the lowest occupied nonce from the middleware's recorded, still-pending routine +transactions and signs the cleanup transaction at that **same nonce**, thereby replacing the +unsafe publication or ratification rather than queueing behind it. The replacement fee bid is +computed from that exact recorded transaction and exceeds both of its EIP-1559 fee fields by at +least the policy's 12.5% replacement bump plus one wei, subject to the protected break-glass +ceilings. The runbook repeats this replacement-or-confirmation step in nonce order before signing +later cleanup transactions. If the middleware cannot reconcile its record with the node's pending +transaction set, the occupied transaction was not recorded, or the protected ceilings cannot +replace it, break-glass must use an ordered drain/handoff and must not claim that a next-unused-nonce +revocation can preempt the pending transaction. Routine transaction ceilings remain below the +protected replacement ceilings so a middleware-produced routine bid cannot strand an otherwise +valid emergency replacement. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw @@ -222,12 +230,18 @@ The set is approved only when **three properties** all hold: are open questions; the property itself is a decided policy requirement. Beneath these three headline properties, **field-level validation** on every offer: market -allowlist; per-market and total exposure caps, enforced against the maker's **current filled positions** -plus **aggregate signed exposure** — the proposed set, the maker's already-live offers from the -middleware's own reads, and every still-outstanding signed-but-unpublished reservation (see -Statefulness), never per-intent amounts alone. Position and offer state come from the same pinned, -independent snapshot, and cap headroom is the configured budget minus filled position exposure, -live offers, and reservations; a fill therefore consumes rather than restores signing room. Expiry +allowlist; per-market and total lend-exposure caps for **exposure-increasing buy offers**, enforced +against the maker's current filled lend position plus aggregate signed buy exposure — the proposed +buys, the maker's already-live buys from the middleware's own reads, and every still-outstanding +signed-but-unpublished buy reservation (see Statefulness), never per-intent amounts alone. Position +and offer state come from the same pinned, independent snapshot, and buy headroom is the configured +budget minus filled lend exposure, live buys, and buy reservations; a buy fill consumes rather than +restores signing room. Lower-rate `reduceOnly` sell offers do **not** consume those lend-exposure +budgets: they can only unwind existing maker credit. They instead draw from a separate per-market +unwind-inventory reservation capped by independently read accrued credit minus live and +signed-but-unpublished reduce-only sell capacity, deduplicated by the same group/cap consumption +domain used by the protocol. This permits safe unwind quotes when lend headroom is zero without +allowing aggregate sell capacity to exceed the credit that can be reduced. Expiry ≤ market maturity and inside a **freshness ceiling** — a policy parameter capping offer lifetime from signing time, so a stockpiled signature dies quickly; offer start not meaningfully before the first validation/reservation time (with the Setter reuse rule below); @@ -264,8 +278,9 @@ exposure. The returned publication payload may be broadcast by any sender withou decision; no security claim depends on that sender returning for a paired publication intent. Ratify and publication still share one ledgered identity: the approved `(maker, root, offer set)` -conditionally creates one per-offer exposure reservation, and later observation of that exact -publication reuses rather than double-charges it. A mismatched set or root is a distinct request and +conditionally creates one per-offer capacity reservation (lend exposure for buys, unwind inventory +for reduce-only sells), and later observation of that exact publication reuses rather than +double-charges it. A mismatched set or root is a distinct request and is evaluated against the existing reservation. After ratification, the only bounds are those encoded in the authorized artifacts and chain state: the offer freshness expiry, the fixed fee cap, group consumption, or root cancellation. A refreshed PnL/market-fee check or a middleware-only @@ -298,6 +313,17 @@ middleware-invoking adapters, selected as a new identity method alongside [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. +Middleware mode also adds an authenticated **signer-identity setup port**. The middleware execution +role, not the bot role, may call `kms:GetPublicKey`; at cold start and before serving signing intents +it derives the secp256k1 address from that public key and fails closed unless it equals the configured +maker. The setup port returns only the validated maker, chain id, middleware policy/configuration +digest, and key fingerprint through the IAM-authenticated invocation response — no public key, +signature, digest-signing primitive, or caller-selected challenge. `SetupStateService` obtains its +derived-maker observation from this port in middleware mode, and `SetupCheckService` keeps the same +configured-maker equality gate it applies to local and direct-KMS identities. Health/readiness is +therefore red when the endpoint, KMS key, chain, or configured maker is mismatched; the check is not +skipped merely because the bot no longer has direct KMS access. + The ports serve **every maker workflow, not only the ladder**: position bootstrap (including auto-refill) signs the same transaction kinds through [`production-bootstrap.ts`](../../bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts), @@ -325,8 +351,10 @@ logic, and any alternative host must preserve the trust split: the middleware al invoke surfaces even though they enforce the same structured revoke intent. - **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are `lambda:InvokeFunction` on this function's intent surfaces — the bot loses `kms:Sign` entirely. - The Lambda's execution role is the only principal with `kms:Sign` on the maker key, plus the - outbound reads its checks need. Creating that execution role, the invoke-only credentials, and + The Lambda's execution role is the only principal with `kms:Sign` and `kms:GetPublicKey` on the + maker key, plus the outbound reads its checks need. `kms:GetPublicKey` exists only for the + fail-closed signer-identity setup proof; it is never granted to the bot. Creating that execution + role, the invoke-only credentials, and the CloudTrail data-event selectors for all four functions (see Observability) are part of the deliverable. - **Caller-to-surface scoping**: principals are authorized per function ARN. The three structured @@ -393,15 +421,18 @@ published, and the freshness ceiling bounds duration, not amount — without a l sign-and-withhold requests could multiply exposure far beyond the caps inside one window. Every intent that makes exposure publishable — an approved **quote or ratify** — therefore records per-offer reservations keyed by maker, derived root, and canonical offer identity (market, group, -side, cap, price, and expiry), while cap accounting records **one exposure amount per protocol -consumption domain** `(maker, market, group, side, cap kind/value)`. In `shared-rung` mode each rung +side, cap, price, and expiry). Capacity accounting records **one amount per protocol consumption +domain** `(maker, market, group, side, cap kind/value)`: buy domains charge per-market and +maker-wide lend-exposure counters, while reduce-only sell domains charge separate per-market +accrued-credit unwind counters and never the lend-exposure counters. In `shared-rung` mode each rung has its own group and cap; in `per-book` mode every leaf on one side repeats the same side-wide cap and shared group, so that cap is counted once rather than multiplied by the rung count. Leaves remain distinct for crossed-book, PnL, publication, and expiry tracking; only repeated shared-cap -exposure is deduplicated. Aggregate caps, crossed-book checks, and PnL checks are enforced over current filled positions, -live offers, and **every outstanding reserved offer** with those semantics. Reservation creation -transactionally updates **atomic aggregate counters** for every affected `(maker, market)` cap, the -maker-wide cap, and the applicable signed-gas budget. Each counter update conditionally requires +capacity is deduplicated. Aggregate lend caps, unwind inventory, crossed-book checks, and PnL checks +are enforced over current filled positions, live offers, and **every outstanding reserved offer** +with those side-specific semantics. Reservation creation transactionally updates **atomic aggregate +counters** for every affected `(maker, market)` lend or unwind cap, the maker-wide lend cap when a +buy is present, and the applicable signed-gas budget. Each counter update conditionally requires enough remaining headroom, and the counter updates, per-offer records, deny-generation fence, and idempotency marker commit as one transaction. Concurrent intents for different roots therefore serialize on shared counter versions instead of both spending the same observed headroom; a failed @@ -434,7 +465,7 @@ account's nonce cursor; the routine single writer and break-glass runbook coordi | Cold start latency | Tolerated; the hourly-ish cadence absorbs it | | Quote intent denied | Typed rejection, nothing signed; alert if persistent | | Revoke intent denied | Near-impossible by design; treat as misconfig, alert | -| Concurrent tx signers (bot + break-glass) | Same-nonce fee-bump replacement resolves on-chain | +| Concurrent tx signers (bot + break-glass) | Replace the lowest recorded occupied nonce; otherwise ordered handoff | | Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | | Reservation ledger unavailable | Quote/ratify/routine revoke closed; break-glass uses independent budget | | Independent revoke budget unavailable | Revoke fails closed and pages operator | @@ -650,7 +681,8 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no leaves while siblings stay reserved, a crossing replacement denied until its retired groups are observed invalidated and approved after, a ratify root that does not match its offer set, `setConsumed` with a foreign `onBehalf` or non-`MAX_OFFER_CAP` amount denied, a credit-reducing - offer without `reduceOnly` denied, a caller-supplied + offer without `reduceOnly` denied, reduce-only sells accepted at zero lend headroom and denied + only when their separate accrued-credit unwind inventory is exhausted, a caller-supplied `continuousFeeCap` above the snapshot fee denied, a root revocation targeting Midnight instead of the ratifier denied, mixed-snapshot reads denied as incoherent, missing or inconsistent API indexed-block metadata denied, ladder and bootstrap offers on a @@ -667,7 +699,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no SDK/bot-side hashing for identical structured input; transaction serialization matches viem's for identical fields. - **Signature correctness:** recovered signer equals the configured maker across both recovery - parities, reusing the existing strict DER/low-s/recovery-check discipline. + parities, reusing the existing strict DER/low-s/recovery-check discipline. The authenticated + signer-identity setup port reports the KMS-derived maker and passes readiness only when endpoint, + chain, key fingerprint, and configured maker agree; each mismatch fails closed without exposing a + generic challenge-signing surface. - **Fail-closed negatives:** generic digest-signing requests are rejected; unknown intent versions are rejected; an independent-read failure produces a typed denial and no KMS call; a missing or invalid policy configuration refuses to serve. @@ -676,7 +711,8 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no invoked by a second IAM principal, an attempted bot invocation of `break-glass-revoke` denied by IAM, a routine-revoke payload that claims break-glass identity still charged only to the routine budget, and the funding-ceiling alert when the maker's native balance exceeds its configured - maximum. + maximum. Queue a routine publication, then prove break-glass replaces that exact occupied nonce + rather than signing the next unused nonce; missing transaction inventory forces ordered handoff. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` after the grant moves, that each role can invoke only its granted intent surfaces, and that a break-glass principal is denied on the quote and ratify surfaces. A denied call is part of From 8796215e2f56d5d5bdb93341949b535751f6d01c Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:23:46 +0000 Subject: [PATCH 17/37] docs(quoter-bot): address reservation and publication review --- ...08-12-quoter-bot-kms-signing-middleware.md | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index ea8b1012..48d9b3da 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -274,8 +274,12 @@ itself. Ratify approval is therefore the **final publication authorization**, no a safety decision that can be revoked by a later middleware check. Before signing the ratification, the middleware performs every quote check against one pinned snapshot, derives the exact root and publication payload, fixes the offer expiry and `continuousFeeCap`, and atomically reserves the -exposure. The returned publication payload may be broadcast by any sender without another policy -decision; no security claim depends on that sender returning for a paired publication intent. +exposure. The returned publication payload is submitted through a publication-broadcaster port +backed by a separate, minimally funded **non-maker** account. That adapter accepts only the exact +zero-value Mempool target and calldata returned by the ratify intent; it has no maker key or +loan-asset authority. The sender adds no policy decision — no security claim depends on it returning +for a paired publication intent — but it gives Setter ladder, bootstrap, and auto-refill flows an +explicit way to put the already-authorized payload onchain after generic maker signing is removed. Ratify and publication still share one ledgered identity: the approved `(maker, root, offer set)` conditionally creates one per-offer capacity reservation (lend exposure for buys, unwind inventory @@ -307,8 +311,9 @@ signed, by construction. The viem `LocalAccount.sign(hash)` blind-digest surface is exactly what is being removed, so the middleware is deliberately **not** a drop-in `LocalAccount` replacement. The bot-side seam is intent-level ports — a quote-publication port (signed tree plus signed publication transaction), -an invalidation-signing port, and a root-ratification port for Setter deployments — backed by -middleware-invoking adapters, selected as a new identity method alongside +an invalidation-signing port, a root-ratification port for Setter deployments, and the constrained +non-maker publication-broadcaster port described above — backed by middleware-invoking adapters, +selected as a new identity method alongside `private-key`/`keystore`/`aws` in [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. @@ -326,11 +331,13 @@ skipped merely because the bot no longer has direct KMS access. The ports serve **every maker workflow, not only the ladder**: position bootstrap (including auto-refill) signs the same transaction kinds through -[`production-bootstrap.ts`](../../bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts), -and in middleware mode a bootstrap top-up is simply a quote intent in its bootstrap group -namespace — same three properties, same field checks. Because the no-PnL-drop property is strict, -middleware mode disables discounted bootstrap: bootstrap and auto-refill require a non-negative -premium and cannot use the existing negative-premium path. No workflow retains a generic signer. +[`production-bootstrap.ts`](../../bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts). +In middleware mode an Ecrecover bootstrap top-up uses a quote intent in its bootstrap group +namespace; a Setter bootstrap or auto-refill uses the ratify intent and then the constrained +publication-broadcaster port, exactly like the Setter ladder flow. Both paths enforce the same three +properties and field checks. Because the no-PnL-drop property is strict, middleware mode disables +discounted bootstrap: bootstrap and auto-refill require a non-negative premium and cannot use the +existing negative-premium path. No workflow retains a generic maker signer. The ports are **transport-agnostic**: they express intents, not hosts. The Lambda invoker is one adapter behind them; plugging in a different middleware host tomorrow — an HTTP API, a Cloudflare @@ -440,17 +447,28 @@ condition causes a fresh snapshot and full re-evaluation, never a partial reserv creation makes an exact Setter ratify/publication observation idempotent and cannot double-count or double-reserve it. +v0 uses DynamoDB and sets a **middleware-mode rung cap of 40 per side** (80 offers), lower than the +general quoter limit. The reservation planner has a fixed, tested write-action budget covering the +80 per-offer records plus every aggregate counter, generation condition, gas-budget update, and +idempotency marker, and rejects any plan above DynamoDB's 100-action `TransactWriteItems` limit +before validation or signing. No request is chunked across transactions: raising the cap requires a +store or schema that can atomically commit the larger maximum tree, plus updated boundary tests. + Publication is tracked per leaf, not merely per root. A reservation moves from reserved to live -when that exact offer is observed and is released on its own freshness expiry. Observed group -consumption and Ecrecover root cancellation are also **root cancellation as release conditions** for -every affected reserved leaf, including signed-but-unpublished leaves that can no longer become -takeable. Setter `false` releases only after the older signed `true` transaction has been replaced or -otherwise made unusable and that outcome is final; irreversible group consumption remains the -authoritative immediate Setter release signal. Releases decrement the same aggregate counters -transactionally and carry an idempotent terminal marker, so observation, cancellation, and expiry -races cannot free capacity twice. Observing one live leaf never releases a still-publishable sibling -merely because they share a root. The ceiling and terminal cleanup keep the ledger tiny and -self-expiring. The reservation ledger tracks the +when that exact offer is observed and is released on its own freshness expiry. Partial group +consumption is not a terminal release condition: reconciliation keeps the group's remaining +takeable capacity reserved and moves consumed buy capacity into the independently read filled +position, so a buy fill does not reopen aggregate lend headroom. A group reservation is terminally +released only when consumption reaches the affected cap, including `MAX_OFFER_CAP` cancellation. +Ecrecover root cancellation is also a terminal release condition for every affected reserved leaf, +including signed-but-unpublished leaves that can no longer become takeable. Setter `false` releases +only after the older signed `true` transaction has been replaced or otherwise made unusable and that +outcome is final; cap-reaching irreversible group consumption remains the authoritative immediate +Setter release signal. Releases decrement the same aggregate counters transactionally and carry an +idempotent terminal marker, so observation, cancellation, and expiry races cannot free capacity +twice. Observing one live leaf never releases a still-publishable sibling merely because they share +a root. The ceiling and terminal cleanup keep the ledger tiny and self-expiring. The reservation +ledger tracks the routine signed-gas budget and the break-glass-principal-only protected revoke reserve; the separate ledger-independent emergency allowance is consumed only by that break-glass principal during a ledger outage. Together those budgets make the bounded-loss claim hold. The middleware validates @@ -553,8 +571,10 @@ host itself; it strengthens rather than changes this design. providers and/or quorum agreement** — because a single lying or censoring provider is the remaining way to get a bad set past those two checks (open question 7). Reads for one intent are pinned to a single deterministic snapshot; a mixed-block view is a denial, not an input. -- A small managed store with conditional writes (e.g. DynamoDB) is available to the Lambda for - the reservation ledger and invoke-surface-and-intent-keyed signed-gas budgets. It holds no +- DynamoDB is available to the Lambda for the reservation ledger and + invoke-surface-and-intent-keyed signed-gas budgets. Middleware mode enforces the 40-rung-per-side + cap so every reservation plan fits one 100-action transaction; configuration and runtime guards + reject larger plans before signing. The store holds no secrets; its unavailability fails quote/ratify and bot-originated routine revokes closed, while break-glass revocation must atomically consume the independently stored emergency budget or fail closed and page the operator. @@ -582,8 +602,8 @@ host itself; it strengthens rather than changes this design. `ListTakeableOfferResponse` exposes only `cursor` and `data`, so adding and consuming this metadata is a blocking v0 deliverable; middleware quote/ratify signing must remain disabled until an integration test proves all policy reads can be pinned to one indexed block. -- A small managed state store for the reservation ledger and signed-gas budget (e.g. DynamoDB - with conditional writes). +- DynamoDB for the reservation ledger and signed-gas budget, with conditional writes and the + middleware-only rung cap that keeps each reservation within one transaction. - `@morpho-org/midnight-sdk` offer-tree EIP-712 hashing for canonical encoding inside the Lambda. - viem for transaction serialization and signature parsing/verification in the Lambda. - [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) for the V1 security gate this TIB From 915ffa5a9c3a6557bd604debce615bdbfbea1006 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:49:40 +0000 Subject: [PATCH 18/37] docs(quoter-bot): harden signing failure and nonce handling --- ...08-12-quoter-bot-kms-signing-middleware.md | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 48d9b3da..5484ef8d 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -152,22 +152,30 @@ Because a signed transaction commits to an account nonce, transaction-signing in routine operation the middleware reads the maker's current pending nonce independently and signs only that nonce; it never signs a stockpile of future-nonce transactions. The bot's serialized make/pending queue remains the single routine writer, and the middleware records every returned -routine transaction's nonce, hash, intent kind, and fee fields before releasing it. +routine transaction's nonce, hash, intent kind, and fee fields before releasing it. Once one +routine transaction has been returned for the current pending nonce, the middleware refuses another +routine signature at that nonce until the recorded transaction is terminal (confirmed, replaced, +cancelled, or expired under the release rules). This same-nonce fence prevents a caller from +withholding several alternatives and choosing the one least favorable to break-glass cleanup. A break-glass revocation deliberately takes over the account's transaction stream during an -incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It first -selects the lowest occupied nonce from the middleware's recorded, still-pending routine -transactions and signs the cleanup transaction at that **same nonce**, thereby replacing the -unsafe publication or ratification rather than queueing behind it. The replacement fee bid is -computed from that exact recorded transaction and exceeds both of its EIP-1559 fee fields by at -least the policy's 12.5% replacement bump plus one wei, subject to the protected break-glass -ceilings. The runbook repeats this replacement-or-confirmation step in nonce order before signing -later cleanup transactions. If the middleware cannot reconcile its record with the node's pending -transaction set, the occupied transaction was not recorded, or the protected ceilings cannot -replace it, break-glass must use an ordered drain/handoff and must not claim that a next-unused-nonce -revocation can preempt the pending transaction. Routine transaction ceilings remain below the -protected replacement ceilings so a middleware-produced routine bid cannot strand an otherwise -valid emergency replacement. +incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It +enumerates every occupied nonce from the middleware's recorded, still-pending routine transactions +and signs a cleanup transaction at each **same nonce**, thereby replacing every unsafe publication +or ratification rather than queueing behind it. Each replacement fee bid exceeds the maximum +recorded fee fields across every routine signature at that nonce by at least the policy's 12.5% +replacement bump plus one wei, subject to the protected break-glass ceilings; the maximum rule is +defense in depth if the same-nonce routine fence was bypassed or older records predate it. The +runbook signs and broadcasts replacements for every occupied nonce in ascending order before +waiting for any replacement to confirm. This prevents a pending transaction at nonce N+1 from +mining in the same block immediately after cleanup at nonce N. Only after the entire occupied +prefix has a replacement in the network may the operator wait for confirmations and proceed with +later, previously unused nonces. If the middleware cannot reconcile its record with the node's +pending transaction set, an occupied transaction was not recorded, or the protected ceilings +cannot replace every occupied nonce, break-glass must use an ordered drain/handoff and must not +claim that a next-unused-nonce revocation can preempt the pending stream. Routine transaction +ceilings remain below the protected replacement ceilings so a middleware-produced routine bid +cannot strand an otherwise valid emergency replacement. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw @@ -447,6 +455,17 @@ condition causes a fresh snapshot and full re-evaluation, never a partial reserv creation makes an exact Setter ratify/publication observation idempotent and cannot double-count or double-reserve it. +The same transaction creates a unique signing-attempt record in `reserved` state. After KMS returns +a signature, the handler conditionally moves that attempt to `signed` and durably records the +artifact metadata before constructing the response. No signed response is returned before that +durable transition. If KMS or validation/encoding fails first, an idempotent compensation +transaction releases its exposure and signed-gas reservations, writes a terminal `failed` marker, +and returns the typed failure. A retry with the same idempotency key observes that marker rather +than releasing twice. A crash while the attempt is still `reserved` is reconciled by the same +conditional compensation after its short attempt lease: handler ordering guarantees that a +signature was not returned before `signed`, while an attempt already marked `signed` remains +reserved and follows the normal observation, cancellation, or freshness-expiry release rules. + v0 uses DynamoDB and sets a **middleware-mode rung cap of 40 per side** (80 offers), lower than the general quoter limit. The reservation planner has a fixed, tested write-action budget covering the 80 per-offer records plus every aggregate counter, generation condition, gas-budget update, and From b5193f642eccd8443f0a877567a216f1b0e418cf Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:14:00 +0000 Subject: [PATCH 19/37] docs(quoter-bot): address signing middleware review Clarify outage nonce inventory, idempotent signed responses, setup IAM, break-glass fencing, and transaction expiry handling. --- ...08-12-quoter-bot-kms-signing-middleware.md | 117 +++++++++++------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 5484ef8d..e09726ca 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -155,7 +155,9 @@ make/pending queue remains the single routine writer, and the middleware records routine transaction's nonce, hash, intent kind, and fee fields before releasing it. Once one routine transaction has been returned for the current pending nonce, the middleware refuses another routine signature at that nonce until the recorded transaction is terminal (confirmed, replaced, -cancelled, or expired under the release rules). This same-nonce fence prevents a caller from +or cancelled on chain). Offer freshness expiry is not terminal for an EOA transaction: the signed +bytes remain broadcastable, so the transaction record and its signed-gas/nonce reservations remain +fenced until the nonce is confirmed or replaced/cancelled on chain. This same-nonce fence prevents a caller from withholding several alternatives and choosing the one least favorable to break-glass cleanup. A break-glass revocation deliberately takes over the account's transaction stream during an @@ -196,7 +198,14 @@ store, sized only for the configured full-book cleanup. The same independent con and group whose signature could make exposure publishable. Catalog persistence is a write-before- sign condition for quote and ratify; if both the primary ledger and catalog cannot durably record an entry, signing fails closed. Break-glass therefore retains the targets needed to cancel hidden, -signed-but-unpublished exposure even when the primary reservation ledger cannot be read. Reserved concurrency +signed-but-unpublished exposure even when the primary reservation ledger cannot be read. It also +keeps a **ledger-independent transaction inventory** of every returned routine transaction's exact +signed bytes, nonce, hash, intent kind, and fee fields. Persisting that record is a write-before- +return condition for every routine transaction; if the independent inventory cannot durably record +it, the routine signing request fails closed. During a primary-ledger outage, break-glass uses this +inventory to replace every occupied nonce with a fee bid derived from the recorded maximum. If the +inventory is unavailable or cannot account for the node's pending set, cleanup fails closed into the +ordered drain/handoff posture instead of claiming ledger-outage preemption. Reserved concurrency isolates revoke capacity from quote floods but is explicitly **not** a rate limit; it cannot bound sequential signatures. If that independent budget cannot be checked atomically, revoke signing fails closed and pages the operator rather than becoming unmetered. The final backstop is a @@ -360,25 +369,29 @@ an HTTP API, a Cloudflare Worker — replaces the handler and the bot-side adapt logic, and any alternative host must preserve the trust split: the middleware alone holds `kms:Sign`, and callers hold nothing but the right to invoke it. -- The middleware is a set of **four AWS Lambda functions — quote, ratify, routine revoke, and - break-glass revoke — built from one shared container image** and invoked through the AWS SDK +- The middleware is a set of **five AWS Lambda functions — setup/health, quote, ratify, routine + revoke, and break-glass revoke — built from one shared container image** and invoked through the AWS SDK (`lambda:InvokeFunction`). Routine and break-glass revoke deliberately have separate authenticated - invoke surfaces even though they enforce the same structured revoke intent. + invoke surfaces even though they enforce the same structured revoke intent. The setup/health + function is the authenticated signer-identity setup port used by readiness; it can return only the + validated setup fields described above and has no signing-intent handler. - **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are - `lambda:InvokeFunction` on this function's intent surfaces — the bot loses `kms:Sign` entirely. - The Lambda's execution role is the only principal with `kms:Sign` and `kms:GetPublicKey` on the - maker key, plus the outbound reads its checks need. `kms:GetPublicKey` exists only for the - fail-closed signer-identity setup proof; it is never granted to the bot. Creating that execution - role, the invoke-only credentials, and - the CloudTrail data-event selectors for all four functions (see Observability) are part of the + `lambda:InvokeFunction` on setup/health and its routine intent surfaces — the bot loses `kms:Sign` + and `kms:GetPublicKey` entirely. The four signing functions' execution roles are the only + principals with `kms:Sign`; the setup/health execution role alone has `kms:GetPublicKey` on the + maker key plus the outbound reads its check needs, and has no `kms:Sign`. Creating those execution + roles, the invoke-only credentials, and + the CloudTrail data-event selectors for all five functions (see Observability) are part of the deliverable. - **Caller-to-surface scoping**: principals are authorized per function ARN. The three structured - intent types map to **four Lambda functions** — one shared container image, four deployments — + intent types map to **four signing Lambda functions**, while setup/readiness maps to the fifth + setup/health function — one shared container image, five deployments — because routine and break-glass revoke must be distinguishable by an authenticated AWS boundary, not by untrusted payload data. Aliases are not enough: reserved concurrency is function-scoped, and distinct functions also let IAM deny the bot access to the protected reserve. IAM grants the - bot quote, ratify, and routine-revoke only; break-glass principals receive break-glass-revoke - only. Each handler pins its intent type and budget class in deployment configuration before + bot setup/health, quote, ratify, and routine-revoke only; break-glass principals receive + break-glass-revoke only. Each signing handler pins its intent type and budget class in deployment + configuration before calling the shared validator. It never reads a claimed principal or budget class from the intent payload or `ClientContext`. Leaked break-glass credentials must yield revocations, never signed quotes or ratifications, while leaked bot credentials can never consume emergency capacity. @@ -388,7 +401,7 @@ logic, and any alternative host must preserve the trust split: the middleware al intents a given principal may submit, and invoke scoping keeps revoke-griefing/DoS hard and the audit trail attributable. - The middleware's **code lives in this monorepo** and deploys as one **Docker container image** - (ECR-hosted) instantiated as the four functions, with its own Dockerfile, like the + (ECR-hosted) instantiated as the five functions, with its own Dockerfile, like the bots. It is not a bot — not a long-running program — so it does not live under `/bots/`; the proposed workspace home is a new top-level `services/` directory, e.g. `services/quoter-signer`. Final naming and location @@ -410,8 +423,9 @@ logic, and any alternative host must preserve the trust split: the middleware al path — and that surface cannot produce quote signatures. The bot cannot invoke this function; its separate routine-revoke function cannot select the protected budget in request data. - Entering break-glass mode first atomically increments a durable **deny generation** in middleware - policy and disables the routine quote and ratify invoke grants before cleanup starts. Every - quote/ratify handler captures that generation on admission and acquires a generation-scoped + policy and disables the routine quote, ratify, and routine-revoke invoke grants before cleanup + starts. Every routine signing handler captures that generation on admission and acquires a + generation-scoped signing lease in the same transaction that reserves aggregate capacity. The handler checks the lease immediately before KMS signing and releases it only after the result is durably cataloged. Break-glass transition denies new leases and waits for every older-generation lease to drain (or @@ -419,8 +433,9 @@ logic, and any alternative host must preserve the trust split: the middleware al before emergency deny can therefore either finish as known revocation inventory before cleanup or produce no signature; it cannot return an untracked fresh signature during cleanup. If lease state cannot be checked or drained, cleanup does not claim containment and pages the operator. Routine - principals cannot obtain fresh quote-enabling signatures until an independently authorized - operator clears the deny after verifying cleanup; revocation remains available throughout. + principals cannot obtain fresh quote, ratify, or routine-revoke signatures until an independently + authorized operator clears the deny after verifying cleanup; only operator-authorized + break-glass revocation remains available throughout. - Setter cleanup treats irreversible group cancellations as authoritative. The runbook drains or replaces every already-signed ratification transaction and consumes every affected offer group; `setIsRootRatified(..., false)` is defense in depth, not the sole kill switch, because an older @@ -457,14 +472,18 @@ double-reserve it. The same transaction creates a unique signing-attempt record in `reserved` state. After KMS returns a signature, the handler conditionally moves that attempt to `signed` and durably records the -artifact metadata before constructing the response. No signed response is returned before that -durable transition. If KMS or validation/encoding fails first, an idempotent compensation +**complete signed response artifacts** — canonical encoded payloads, every signature, and any exact +signed transaction bytes plus nonce/hash/fee fields — before constructing the response. A retry with +the same idempotency key returns those exact stored artifacts and cannot invoke KMS again. No signed +response is returned before that durable transition. If KMS or validation/encoding fails first, an idempotent compensation transaction releases its exposure and signed-gas reservations, writes a terminal `failed` marker, and returns the typed failure. A retry with the same idempotency key observes that marker rather than releasing twice. A crash while the attempt is still `reserved` is reconciled by the same conditional compensation after its short attempt lease: handler ordering guarantees that a signature was not returned before `signed`, while an attempt already marked `signed` remains -reserved and follows the normal observation, cancellation, or freshness-expiry release rules. +reserved. Offer exposure follows the normal observation, cancellation, or freshness-expiry release +rules, but EOA transaction records and their signed-gas/nonce reservations never release on offer +freshness expiry; they remain until the nonce is confirmed or replaced/cancelled on chain. v0 uses DynamoDB and sets a **middleware-mode rung cap of 40 per side** (80 offers), lower than the general quoter limit. The reservation planner has a fixed, tested write-action budget covering the @@ -496,19 +515,19 @@ account's nonce cursor; the routine single writer and break-glass runbook coordi ### 7. Failure posture -| Failure | Required behavior | -| ----------------------------------------- | ----------------------------------------------------------------------- | -| Invocation fails (throttle, error, limit) | Halt quoting (fail closed) and retry; offers stand | -| Cold start latency | Tolerated; the hourly-ish cadence absorbs it | -| Quote intent denied | Typed rejection, nothing signed; alert if persistent | -| Revoke intent denied | Near-impossible by design; treat as misconfig, alert | -| Concurrent tx signers (bot + break-glass) | Replace the lowest recorded occupied nonce; otherwise ordered handoff | -| Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | -| Reservation ledger unavailable | Quote/ratify/routine revoke closed; break-glass uses independent budget | -| Independent revoke budget unavailable | Revoke fails closed and pages operator | -| KMS error | Typed failure; never assume a signature was produced | -| Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | -| Unknown intent type/version | Reject; no best-effort interpretation of payloads | +| Failure | Required behavior | +| ----------------------------------------- | ------------------------------------------------------------------------------- | +| Invocation fails (throttle, error, limit) | Halt quoting (fail closed) and retry; offers stand | +| Cold start latency | Tolerated; the hourly-ish cadence absorbs it | +| Quote intent denied | Typed rejection, nothing signed; alert if persistent | +| Revoke intent denied | Near-impossible by design; treat as misconfig, alert | +| Concurrent tx signers (bot + break-glass) | Replace every recorded occupied nonce before waiting; otherwise ordered handoff | +| Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | +| Reservation ledger unavailable | Quote/ratify/routine revoke closed; break-glass uses independent budget | +| Independent revoke budget unavailable | Revoke fails closed and pages operator | +| KMS error | Typed failure; never assume a signature was produced | +| Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | +| Unknown intent type/version | Reject; no best-effort interpretation of payloads | ## Considered Alternatives @@ -576,8 +595,9 @@ host itself; it strengthens rather than changes this design. ## Assumptions & Constraints - IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly - quote, ratify, and routine-revoke and nothing else; the Lambda execution roles are the sole - `kms:Sign` principals on the maker key; break-glass operators hold only the separate + setup/health, quote, ratify, and routine-revoke and nothing else; the four signing Lambda + execution roles are the sole `kms:Sign` principals on the maker key, while the non-signing + setup/health role alone holds `kms:GetPublicKey`; break-glass operators hold only the separate break-glass-revoke invoke grant. Policy selects the budget from that fixed function deployment, never from caller-controlled request data. - Every signed payload class is fully describable as structured intents and canonically @@ -649,7 +669,7 @@ host itself; it strengthens rather than changes this design. side is an incident signal. Lambda `Invoke` is a CloudTrail **data event and is not logged by default** ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); - enabling data-event selectors for all four function ARNs is an explicit v0 deliverable — + enabling data-event selectors for all five function ARNs is an explicit v0 deliverable — without complete coverage, the invoke side of this audit trail silently omits intent surfaces. - Alerting on denials, invocation errors/throttles, KMS errors, and independent-read failures. Bot-side `make.rejected` events extend with middleware-denial reasons; invocation-failure halts @@ -750,12 +770,23 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no invoked by a second IAM principal, an attempted bot invocation of `break-glass-revoke` denied by IAM, a routine-revoke payload that claims break-glass identity still charged only to the routine budget, and the funding-ceiling alert when the maker's native balance exceeds its configured - maximum. Queue a routine publication, then prove break-glass replaces that exact occupied nonce - rather than signing the next unused nonce; missing transaction inventory forces ordered handoff. + maximum. Queue routine publications at consecutive occupied nonces, then prove break-glass + pre-signs and broadcasts replacements for every occupied nonce before waiting rather than signing + only the lowest or the next unused nonce. Prove primary-ledger outage cleanup uses the independent + transaction inventory's exact nonce and fee records; a missing or incoherent inventory forces + ordered handoff. Enter break-glass while routine-revoke is in flight and prove its generation lease + drains before cleanup and no new routine revoke can be signed until emergency deny is cleared. +- **Retry and delayed-broadcast safety:** drop the first Lambda response after the durable `signed` + transition and prove an idempotent retry returns byte-identical stored artifacts without a second + KMS call. Withhold a signed transaction beyond its offer freshness expiry and prove its transaction + record, nonce fence, and signed-gas reservation remain until that nonce is confirmed or replaced/ + cancelled on chain. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` - after the grant moves, that each role can invoke only its granted intent surfaces, and that a - break-glass principal is denied on the quote and ratify surfaces. A denied call is part of - acceptance, not an incident. + and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only + its constrained response, that the setup/health role cannot call `kms:Sign`, that each role can + invoke only its granted surfaces, and that a break-glass principal is denied on setup/health, + quote, and ratify. Verify CloudTrail data events cover all five function ARNs. A denied call is part + of acceptance, not an incident. - Tests follow the repository verification rule: run each new test, break one assertion to confirm it fails, restore it. From c9176b415ec36b9177d9d0ae401212862da37949 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:33:59 +0000 Subject: [PATCH 20/37] docs(quoter-bot): address signing middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 102 +++++++++++------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index e09726ca..230aa1f0 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -151,14 +151,18 @@ Because a signed transaction commits to an account nonce, transaction-signing in **caller-supplied fee fields** as liveness parameters, but nonce ordering is policy-relevant. In routine operation the middleware reads the maker's current pending nonce independently and signs only that nonce; it never signs a stockpile of future-nonce transactions. The bot's serialized -make/pending queue remains the single routine writer, and the middleware records every returned -routine transaction's nonce, hash, intent kind, and fee fields before releasing it. Once one -routine transaction has been returned for the current pending nonce, the middleware refuses another -routine signature at that nonce until the recorded transaction is terminal (confirmed, replaced, -or cancelled on chain). Offer freshness expiry is not terminal for an EOA transaction: the signed -bytes remain broadcastable, so the transaction record and its signed-gas/nonce reservations remain -fenced until the nonce is confirmed or replaced/cancelled on chain. This same-nonce fence prevents a caller from -withholding several alternatives and choosing the one least favorable to break-glass cleanup. +make/pending queue remains the single routine writer, but the middleware does not rely on that +process-local serialization. The reservation transaction conditionally acquires a nonce-specific +lease before any routine KMS call and fails if that nonce is already occupied by a live lease or +transaction record. The lease records the intent kind and fee fields and is atomically converted to +the returned transaction's nonce/hash record before release; concurrent quote, ratify, and routine- +revoke invocations therefore cannot obtain alternative signatures at the same nonce. The +middleware refuses another routine signature at that nonce until the recorded transaction is +terminal (confirmed, replaced, or cancelled on chain). Offer freshness expiry is not terminal for +an EOA transaction: the signed bytes remain broadcastable, so the transaction record and its +signed-gas/nonce reservations remain fenced until the nonce is confirmed or replaced/cancelled on +chain. This same-nonce fence prevents a caller from withholding several alternatives and choosing +the one least favorable to break-glass cleanup. A break-glass revocation deliberately takes over the account's transaction stream during an incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It @@ -196,8 +200,10 @@ atomically consumed token/gas/nonce allowance on an independently operated high- store, sized only for the configured full-book cleanup. The same independent control plane keeps a **ledger-independent revocation inventory**: an append-only, read-replicated catalog of every root and group whose signature could make exposure publishable. Catalog persistence is a write-before- -sign condition for quote and ratify; if both the primary ledger and catalog cannot durably record -an entry, signing fails closed. Break-glass therefore retains the targets needed to cancel hidden, +sign condition for quote and ratify: **both** the primary reservation and the independent catalog +entry must commit durably before any KMS call. Failure of either write fails signing closed; the +middleware never signs with uncharged aggregate capacity or an incomplete revocation inventory. +Break-glass therefore retains the targets needed to cancel hidden, signed-but-unpublished exposure even when the primary reservation ledger cannot be read. It also keeps a **ledger-independent transaction inventory** of every returned routine transaction's exact signed bytes, nonce, hash, intent kind, and fee fields. Persisting that record is a write-before- @@ -335,16 +341,20 @@ selected as a new identity method alongside [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. -Middleware mode also adds an authenticated **signer-identity setup port**. The middleware execution -role, not the bot role, may call `kms:GetPublicKey`; at cold start and before serving signing intents -it derives the secp256k1 address from that public key and fails closed unless it equals the configured -maker. The setup port returns only the validated maker, chain id, middleware policy/configuration -digest, and key fingerprint through the IAM-authenticated invocation response — no public key, -signature, digest-signing primitive, or caller-selected challenge. `SetupStateService` obtains its -derived-maker observation from this port in middleware mode, and `SetupCheckService` keeps the same -configured-maker equality gate it applies to local and direct-KMS identities. Health/readiness is -therefore red when the endpoint, KMS key, chain, or configured maker is mismatched; the check is not -skipped merely because the bot no longer has direct KMS access. +Middleware mode also adds an authenticated **signer-identity setup port**. Every function execution +role, not the bot role, may call `kms:GetPublicKey` only on the configured maker key. At cold start +and before serving, each of the five deployments derives the secp256k1 address and key fingerprint, +computes its effective policy/configuration digest, and fails closed unless all three equal the +expected deployment manifest. Setup/health aggregates those per-surface attestations and is ready +only when setup, quote, ratify, routine-revoke, and break-glass-revoke report the same maker, chain, +key fingerprint, image digest, and policy/configuration digest. A correct setup function therefore +cannot mask a stale key or policy on a signing surface. The setup port returns only those validated +fields through the IAM-authenticated invocation response — no public key, signature, digest-signing +primitive, or caller-selected challenge. `SetupStateService` obtains its derived-maker observation +from this port in middleware mode, and `SetupCheckService` keeps the same configured-maker equality +gate it applies to local and direct-KMS identities. Health/readiness is red when any endpoint, KMS +key, chain, image, policy, or configured maker is mismatched; the check is not skipped merely because +the bot no longer has direct KMS access. The ports serve **every maker workflow, not only the ladder**: position bootstrap (including auto-refill) signs the same transaction kinds through @@ -378,9 +388,9 @@ logic, and any alternative host must preserve the trust split: the middleware al - **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are `lambda:InvokeFunction` on setup/health and its routine intent surfaces — the bot loses `kms:Sign` and `kms:GetPublicKey` entirely. The four signing functions' execution roles are the only - principals with `kms:Sign`; the setup/health execution role alone has `kms:GetPublicKey` on the - maker key plus the outbound reads its check needs, and has no `kms:Sign`. Creating those execution - roles, the invoke-only credentials, and + principals with `kms:Sign`; all five execution roles have narrowly scoped `kms:GetPublicKey` on + that same maker key solely for their startup attestation, while setup/health has no `kms:Sign`. + Creating those execution roles, the invoke-only credentials, and the CloudTrail data-event selectors for all five functions (see Observability) are part of the deliverable. - **Caller-to-surface scoping**: principals are authorized per function ARN. The three structured @@ -464,7 +474,10 @@ with those side-specific semantics. Reservation creation transactionally updates counters** for every affected `(maker, market)` lend or unwind cap, the maker-wide lend cap when a buy is present, and the applicable signed-gas budget. Each counter update conditionally requires enough remaining headroom, and the counter updates, per-offer records, deny-generation fence, and -idempotency marker commit as one transaction. Concurrent intents for different roots therefore +idempotency marker commit as one transaction. The marker stores a hash of the canonical versioned +intent, including its invoke surface; reuse of an idempotency key with any different canonical hash +is a typed conflict and returns no stored artifact or new signature. Concurrent intents for +different roots therefore serialize on shared counter versions instead of both spending the same observed headroom; a failed condition causes a fresh snapshot and full re-evaluation, never a partial reservation. Conditional creation makes an exact Setter ratify/publication observation idempotent and cannot double-count or @@ -474,8 +487,9 @@ The same transaction creates a unique signing-attempt record in `reserved` state a signature, the handler conditionally moves that attempt to `signed` and durably records the **complete signed response artifacts** — canonical encoded payloads, every signature, and any exact signed transaction bytes plus nonce/hash/fee fields — before constructing the response. A retry with -the same idempotency key returns those exact stored artifacts and cannot invoke KMS again. No signed -response is returned before that durable transition. If KMS or validation/encoding fails first, an idempotent compensation +the same idempotency key returns those exact stored artifacts without another KMS call only after +the canonical intent hash matches the marker; a mismatch is rejected. No signed response is returned +before that durable transition. If KMS or validation/encoding fails first, an idempotent compensation transaction releases its exposure and signed-gas reservations, writes a terminal `failed` marker, and returns the typed failure. A retry with the same idempotency key observes that marker rather than releasing twice. A crash while the attempt is still `reserved` is reconciled by the same @@ -510,8 +524,9 @@ ledger tracks the routine signed-gas budget and the break-glass-principal-only protected revoke reserve; the separate ledger-independent emergency allowance is consumed only by that break-glass principal during a ledger outage. Together those budgets make the bounded-loss claim hold. The middleware validates -routine transaction nonces against the current pending nonce but does not allocate or advance the -account's nonce cursor; the routine single writer and break-glass runbook coordinate the stream. +routine transaction nonces against the current pending nonce and conditionally leases that nonce +before KMS signing, but does not allocate or advance the account's nonce cursor; the durable lease, +routine single writer, and break-glass runbook coordinate the stream. ### 7. Failure posture @@ -596,10 +611,10 @@ host itself; it strengthens rather than changes this design. - IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly setup/health, quote, ratify, and routine-revoke and nothing else; the four signing Lambda - execution roles are the sole `kms:Sign` principals on the maker key, while the non-signing - setup/health role alone holds `kms:GetPublicKey`; break-glass operators hold only the separate - break-glass-revoke invoke grant. Policy selects the budget from that fixed function deployment, - never from caller-controlled request data. + execution roles are the sole `kms:Sign` principals on the maker key, while all five function + roles hold narrowly scoped `kms:GetPublicKey` solely for per-surface startup attestation; + break-glass operators hold only the separate break-glass-revoke invoke grant. Policy selects the + budget from that fixed function deployment, never from caller-controlled request data. - Every signed payload class is fully describable as structured intents and canonically encodable inside the Lambda (SDK EIP-712 offer-tree hashing and Mempool payload encoding; viem transaction serialization). @@ -613,8 +628,12 @@ host itself; it strengthens rather than changes this design. - DynamoDB is available to the Lambda for the reservation ledger and invoke-surface-and-intent-keyed signed-gas budgets. Middleware mode enforces the 40-rung-per-side cap so every reservation plan fits one 100-action transaction; configuration and runtime guards - reject larger plans before signing. The store holds no - secrets; its unavailability fails quote/ratify and bot-originated routine revokes closed, while + reject larger plans before signing. The primary ledger and independent transaction/revocation + inventories hold **sensitive bearer capabilities** whenever they persist signatures, signed + transactions, or publishable payloads. They require encryption at rest and in transit, + least-privilege read access, no ordinary diagnostic/read-replica access, audited access, and + retention/deletion controls aligned with terminal reservation state. Their unavailability fails + quote/ratify and bot-originated routine revokes closed, while break-glass revocation must atomically consume the independently stored emergency budget or fail closed and page the operator. - The maker EOA's native balance is operationally bounded: funded above the `NATIVE_RESERVE_WEI` @@ -759,9 +778,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no for identical fields. - **Signature correctness:** recovered signer equals the configured maker across both recovery parities, reusing the existing strict DER/low-s/recovery-check discipline. The authenticated - signer-identity setup port reports the KMS-derived maker and passes readiness only when endpoint, - chain, key fingerprint, and configured maker agree; each mismatch fails closed without exposing a - generic challenge-signing surface. + signer-identity setup port reports the KMS-derived maker and passes readiness only when every + deployed surface attests the same endpoint, chain, key fingerprint, image digest, policy digest, + and configured maker; drift on any one signing function fails closed without exposing a generic + challenge-signing surface. - **Fail-closed negatives:** generic digest-signing requests are rejected; unknown intent versions are rejected; an independent-read failure produces a typed denial and no KMS call; a missing or invalid policy configuration refuses to serve. @@ -778,12 +798,16 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no drains before cleanup and no new routine revoke can be signed until emergency deny is cleared. - **Retry and delayed-broadcast safety:** drop the first Lambda response after the durable `signed` transition and prove an idempotent retry returns byte-identical stored artifacts without a second - KMS call. Withhold a signed transaction beyond its offer freshness expiry and prove its transaction + KMS call. Reuse that key with a different canonical intent and prove it returns a typed conflict, + no artifacts, and no KMS call. Race two routine signing intents at the same pending nonce and prove + exactly one acquires the pre-sign nonce lease and reaches KMS. Withhold a signed transaction beyond + its offer freshness expiry and prove its transaction record, nonce fence, and signed-gas reservation remain until that nonce is confirmed or replaced/ cancelled on chain. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only - its constrained response, that the setup/health role cannot call `kms:Sign`, that each role can + its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned + maker key, that the setup/health role cannot call `kms:Sign`, that each role can invoke only its granted surfaces, and that a break-glass principal is denied on setup/health, quote, and ratify. Verify CloudTrail data events cover all five function ARNs. A denied call is part of acceptance, not an incident. From b019da5699fc780609142fd2b528644da0905e13 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:56:34 +0000 Subject: [PATCH 21/37] docs(quoter-bot): address signing middleware review Clarify routine replacement liveness, emergency fee headroom, per-surface attestation collection, and the break-glass deny transition. --- ...08-12-quoter-bot-kms-signing-middleware.md | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 230aa1f0..57c61f90 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -164,6 +164,17 @@ signed-gas/nonce reservations remain fenced until the nonce is confirmed or repl chain. This same-nonce fence prevents a caller from withholding several alternatives and choosing the one least favorable to break-glass cleanup. +The fence has one routine liveness exception: the same authenticated surface may request a +**replacement** of its recorded, still-pending transaction. The middleware accepts only the exact +canonical intent and economic payload already recorded at that nonce, or an exact zero-value +self-cancel; the request cannot change offers, roots, groups, targets, calldata, value, or intent +kind. It independently derives both fee fields using the repository replacement rule +`max(floor(previous * 1125 / 1000), previous + 1 wei)`, applies the routine ceilings and rolling +signed-gas budget, and atomically replaces the recorded fee fields, signed bytes, and hash before +returning the new artifact. Repeated bumps therefore restore a path for an underpriced transaction +without creating a menu of economically different same-nonce signatures. A replacement that +cannot be recorded, budgeted, or reconciled with the pending transaction fails closed. + A break-glass revocation deliberately takes over the account's transaction stream during an incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It enumerates every occupied nonce from the middleware's recorded, still-pending routine transactions @@ -180,8 +191,14 @@ later, previously unused nonces. If the middleware cannot reconcile its record w pending transaction set, an occupied transaction was not recorded, or the protected ceilings cannot replace every occupied nonce, break-glass must use an ordered drain/handoff and must not claim that a next-unused-nonce revocation can preempt the pending stream. Routine transaction -ceilings remain below the protected replacement ceilings so a middleware-produced routine bid -cannot strand an otherwise valid emergency replacement. +ceilings must reserve one complete emergency bump for **both** fee fields: for each routine ceiling +`r`, the corresponding protected ceiling is at least +`max(floor(r * 1125 / 1000), r + 1 wei)`. Deployment validation rejects any weaker configuration, +and before returning every routine transaction (including a routine replacement) the middleware +reapplies the same formula to its actual recorded `maxFeePerGas` and `maxPriorityFeePerGas` and +requires both results to fit the protected ceilings. A middleware-produced routine bid therefore +cannot strand an otherwise valid emergency replacement merely because the ceilings were only one +wei apart. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw @@ -356,6 +373,18 @@ gate it applies to local and direct-KMS identities. Health/readiness is red when key, chain, image, policy, or configured maker is mismatched; the check is not skipped merely because the bot no longer has direct KMS access. +The aggregation path is an internal attestation registry, not an assertion synthesized by +setup/health. After validating itself, each execution role may conditionally write only its own +function-and-published-version key in a dedicated DynamoDB table; the value contains the validated +fields above, the deployment-manifest digest, and a startup timestamp. It cannot write another +surface's key or read the table. The setup/health execution role may write its own key and read all +five exact keys, but may not alter the four signing-surface records; the bot role has no table +access. Readiness resolves every production alias to its current published version and requires a +fresh matching record for that exact version and manifest, so an attestation from a retired +deployment cannot satisfy the check. IAM grants only those per-key `dynamodb:PutItem` operations and +the setup role's bounded `GetItem`/`BatchGetItem`; no attestation path invokes a signing handler or +exposes a signing operation. + The ports serve **every maker workflow, not only the ladder**: position bootstrap (including auto-refill) signs the same transaction kinds through [`production-bootstrap.ts`](../../bots/quoter-bot/src/infrastructure/bootstrap/production-bootstrap.ts). @@ -433,8 +462,15 @@ logic, and any alternative host must preserve the trust split: the middleware al path — and that surface cannot produce quote signatures. The bot cannot invoke this function; its separate routine-revoke function cannot select the protected budget in request data. - Entering break-glass mode first atomically increments a durable **deny generation** in middleware - policy and disables the routine quote, ratify, and routine-revoke invoke grants before cleanup - starts. Every routine signing handler captures that generation on admission and acquires a + policy before cleanup starts. The operator-authorized `break-glass-revoke` function is the control + surface: on its first cleanup request it conditionally acquires a single active cleanup epoch and + increments the deny generation in the same ledger transaction, then refuses to sign any cleanup + transaction until older-generation routine leases have drained. Operators need only + `lambda:InvokeFunction` on that function; its execution role alone has the narrowly scoped ledger + permission to perform this transition. Routine invoke grants may remain present so IAM changes + are not a containment prerequisite — every routine handler denies new leases from the new + generation — while incident automation can disable those grants as defense in depth. Every + routine signing handler captures the generation on admission and acquires a generation-scoped signing lease in the same transaction that reserves aggregate capacity. The handler checks the lease immediately before KMS signing and releases it only after the result is durably cataloged. From f29961ea7dd3328341a76592c0d572028d4361e8 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:14:27 +0000 Subject: [PATCH 22/37] docs(quoter-bot): address middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 81 ++++++++++++------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 57c61f90..00472932 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -168,12 +168,17 @@ The fence has one routine liveness exception: the same authenticated surface may **replacement** of its recorded, still-pending transaction. The middleware accepts only the exact canonical intent and economic payload already recorded at that nonce, or an exact zero-value self-cancel; the request cannot change offers, roots, groups, targets, calldata, value, or intent -kind. It independently derives both fee fields using the repository replacement rule -`max(floor(previous * 1125 / 1000), previous + 1 wei)`, applies the routine ceilings and rolling -signed-gas budget, and atomically replaces the recorded fee fields, signed bytes, and hash before -returning the new artifact. Repeated bumps therefore restore a path for an underpriced transaction -without creating a menu of economically different same-nonce signatures. A replacement that -cannot be recorded, budgeted, or reconciled with the pending transaction fails closed. +kind. It reads the current pending-block base fee independently, derives +`newPriorityFee = max(floor(previousPriorityFee * 1125 / 1000), previousPriorityFee + 1 wei)`, and +derives +`newMaxFee = max(floor(previousMaxFee * 1125 / 1000), previousMaxFee + 1 wei, currentBaseFee * 2 + newPriorityFee)`. +This is the repository fee policy, including its base-fee floor rather than only the replacement +bump. The middleware applies the routine ceilings and rolling signed-gas budget to both derived +fields and atomically replaces the recorded fee fields, signed bytes, and hash before returning the +new artifact. Repeated bumps therefore restore a path for an underpriced transaction without +creating a menu of economically different same-nonce signatures. A replacement that cannot read +the current base fee, be recorded, budgeted, or be reconciled with the pending transaction fails +closed. A break-glass revocation deliberately takes over the account's transaction stream during an incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It @@ -214,7 +219,11 @@ payload or starve the kill switch with otherwise valid cancellations. When the l unavailable, quote/ratify and bot-originated routine revokes fail closed while the break-glass surface draws from a small **ledger-independent emergency-revoke budget**: a durable, atomically consumed token/gas/nonce allowance on an independently operated high-availability -store, sized only for the configured full-book cleanup. The same independent control plane keeps a +store. Deployment validation sizes this allowance for replacements at the **maximum configured +occupied-nonce set** (each at the protected fee and gas ceilings) plus one configured full-book +cleanup, with no overlap assumed between those costs. If the live occupied set exceeds that +configured maximum, outage cleanup fails closed into ordered drain/handoff rather than claiming the +ledger-outage guarantee. The same independent control plane keeps a **ledger-independent revocation inventory**: an append-only, read-replicated catalog of every root and group whose signature could make exposure publishable. Catalog persistence is a write-before- sign condition for quote and ratify: **both** the primary reservation and the independent catalog @@ -461,27 +470,36 @@ logic, and any alternative host must preserve the trust split: the middleware al operator can invoke the revoke intent directly with their own credentials, with no bot in the path — and that surface cannot produce quote signatures. The bot cannot invoke this function; its separate routine-revoke function cannot select the protected budget in request data. -- Entering break-glass mode first atomically increments a durable **deny generation** in middleware - policy before cleanup starts. The operator-authorized `break-glass-revoke` function is the control - surface: on its first cleanup request it conditionally acquires a single active cleanup epoch and - increments the deny generation in the same ledger transaction, then refuses to sign any cleanup - transaction until older-generation routine leases have drained. Operators need only - `lambda:InvokeFunction` on that function; its execution role alone has the narrowly scoped ledger - permission to perform this transition. Routine invoke grants may remain present so IAM changes - are not a containment prerequisite — every routine handler denies new leases from the new - generation — while incident automation can disable those grants as defense in depth. Every - routine signing handler captures the generation on admission and acquires a - generation-scoped - signing lease in the same transaction that reserves aggregate capacity. The handler checks the - lease immediately before KMS signing and releases it only after the result is durably cataloged. - Break-glass transition denies new leases and waits for every older-generation lease to drain (or - be conclusively failed and its reservation released) before cleanup starts. An invocation admitted - before emergency deny can therefore either finish as known revocation inventory before cleanup or - produce no signature; it cannot return an untracked fresh signature during cleanup. If lease state - cannot be checked or drained, cleanup does not claim containment and pages the operator. Routine - principals cannot obtain fresh quote, ratify, or routine-revoke signatures until an independently - authorized operator clears the deny after verifying cleanup; only operator-authorized - break-glass revocation remains available throughout. +- Entering break-glass mode first atomically increments a durable **independent deny generation** + before cleanup starts. The generation, active cleanup epoch, and a mirror of every active routine + signing lease live in the same independently operated high-availability control plane as the + emergency budget, not only in the primary reservation ledger. Every routine handler must read the + independent generation, acquire both its primary reservation and an independent + generation-scoped lease before KMS signing, recheck that independent lease immediately before the + KMS call, and release it only after the result is durably cataloged. Failure of either control + plane fails routine signing closed. +- The operator-authorized `break-glass-revoke` function is the control surface: on its first cleanup + request it conditionally acquires the single active cleanup epoch and increments the independent + deny generation atomically, then refuses to sign cleanup transactions until every older-generation + lease in the independent control plane has drained. It mirrors the deny into the primary ledger + when available, but primary-ledger availability is not required to arm containment. Operators need + only `lambda:InvokeFunction` on that function; its execution role alone has conditional-write + permission for the independent epoch/generation records. Routine invoke grants may remain present + so IAM changes are not a containment prerequisite, while incident automation can disable those + grants as defense in depth. An invocation admitted before emergency deny can therefore either + finish as known revocation inventory before cleanup or produce no signature; it cannot return an + untracked fresh signature during cleanup. If independent lease state cannot be checked or drained, + cleanup does not claim containment and pages the operator. +- Clearing containment is an explicit `close-cleanup-epoch` operation on the same + operator-authorized `break-glass-revoke` function; routine principals cannot invoke it, and it + performs no KMS signing. The handler closes only the caller-specified active epoch after independent + reads prove all replacement/cleanup transactions confirmed, the occupied-nonce inventory empty, + and no older-generation leases remain. Its execution role may conditionally close that exact epoch + and advance the routine-admission generation, but cannot rewrite inventory or budget history. The + operation emits `middleware.cleanup_epoch_closed` with operator principal, epoch, old/new + generation, evidence snapshot, and request ID to the immutable audit stream. Until that + authenticated close succeeds, routine quote, ratify, and routine-revoke remain denied and only + operator-authorized break-glass revocation remains available. - Setter cleanup treats irreversible group cancellations as authoritative. The runbook drains or replaces every already-signed ratification transaction and consumes every affected offer group; `setIsRootRatified(..., false)` is defense in depth, not the sole kill switch, because an older @@ -716,8 +734,11 @@ host itself; it strengthens rather than changes this design. - This log is an **authorization audit trail that survives bot-host compromise** — the bot cannot erase or forge it. - CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot vs - break-glass principals), and `kms:Sign` has exactly one allowed principal, so the KMS call - stream must reconcile against the logged per-artifact signing records. CloudTrail `Sign` + break-glass principals), and `kms:Sign` allows exactly the configured **quote, ratify, + routine-revoke, and break-glass-revoke execution-role ARNs**. The reconciler maintains that + four-role allowlist and maps each role ARN to its one expected intent surface; a call from an + unknown role or a known role signing for another surface is an incident. The KMS call stream for + each role/surface pair must reconcile against the logged per-artifact signing records. CloudTrail `Sign` events do not carry the message or digest, so the join key is the **KMS request ID** each signing record captures: every CloudTrail `Sign` event must match one middleware record and vice versa, at artifact rather than intent granularity. An unmatched or surplus call on either From 665429ef9a4f980fe8d4a3018260d7e6dd3db42a Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:38:08 +0000 Subject: [PATCH 23/37] docs(quoter-bot): address follow-up middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 00472932..4b604035 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -211,9 +211,12 @@ on **rolling signed-gas budgets** tracked in the reservation ledger (see Statefu class comes from the authenticated Lambda invoke surface, never from a caller-supplied principal, intent field, or `ClientContext`: publication, ratification, and the bot-only `routine-revoke` function draw on the routine budget, while the operator-only `break-glass-revoke` function alone -draws on a **protected reserve** — sized for several full-book cleanups — that the bot role has no -IAM permission to invoke or draw down. Both revoke functions use the same strict revoke validator -from the shared image, but their function ARNs, IAM grants, reserved concurrency, and server-side +function draw on a **protected reserve** that the bot role has no IAM permission to invoke or draw +down. The primary protected reserve is sized for replacements at the **maximum configured +occupied-nonce set** (each at the protected fee and gas ceilings) plus several full-book cleanups, +with no overlap assumed between those costs. Deployment validation computes that bound from the +configured maximum and rejects a primary reserve below it. Both revoke functions use the same strict revoke +validator from the shared image, but their function ARNs, IAM grants, reserved concurrency, and server-side budget classes are distinct. A compromised bot therefore cannot impersonate break-glass in its payload or starve the kill switch with otherwise valid cancellations. When the ledger is unavailable, quote/ratify and bot-originated routine revokes fail closed while the break-glass @@ -227,9 +230,16 @@ ledger-outage guarantee. The same independent control plane keeps a **ledger-independent revocation inventory**: an append-only, read-replicated catalog of every root and group whose signature could make exposure publishable. Catalog persistence is a write-before- sign condition for quote and ratify: **both** the primary reservation and the independent catalog -entry must commit durably before any KMS call. Failure of either write fails signing closed; the -middleware never signs with uncharged aggregate capacity or an incomplete revocation inventory. -Break-glass therefore retains the targets needed to cancel hidden, +entry must commit durably in a `pending-signature` state before any KMS call. Failure of either write +fails signing closed; the middleware never signs with uncharged aggregate capacity or an incomplete +revocation inventory. After all signed response artifacts are durable, the finalization transaction +conditionally promotes the catalog entry to `signed`. The same idempotent compensation path that +releases a failed primary reservation also writes a terminal `failed` tombstone for its catalog +entry. Break-glass enumerates only `signed` catalog entries; `pending-signature` entries past their +short lease are reconciled to `signed` only from complete durable artifacts, otherwise they are +tombstoned. If the catalog cannot prove an entry's terminal state, cleanup fails closed into ordered +drain/handoff instead of charging emergency capacity for speculative targets. Break-glass therefore +retains the targets needed to cancel hidden, signed-but-unpublished exposure even when the primary reservation ledger cannot be read. It also keeps a **ledger-independent transaction inventory** of every returned routine transaction's exact signed bytes, nonce, hash, intent kind, and fee fields. Persisting that record is a write-before- @@ -392,7 +402,14 @@ access. Readiness resolves every production alias to its current published versi fresh matching record for that exact version and manifest, so an attestation from a retired deployment cannot satisfy the check. IAM grants only those per-key `dynamodb:PutItem` operations and the setup role's bounded `GetItem`/`BatchGetItem`; no attestation path invokes a signing handler or -exposes a signing operation. +exposes a signing operation. Each published function version also exposes a dedicated **non-signing +attestation entrypoint** in the same image. After publishing a version and before moving its +production alias, deployment automation invokes that entrypoint under the function execution role; +it performs only the key/config/image validation above and the conditional registry write. The +deployment verifies the exact version-and-manifest record, retries transient failures, and refuses +the alias rollout if the record is absent or mismatched. Readiness can therefore require fresh +records without waiting for quote, ratify, or revoke traffic, while setup/health still never invokes +a signing handler. The ports serve **every maker workflow, not only the ladder**: position bootstrap (including auto-refill) signs the same transaction kinds through @@ -538,12 +555,21 @@ creation makes an exact Setter ratify/publication observation idempotent and can double-reserve it. The same transaction creates a unique signing-attempt record in `reserved` state. After KMS returns -a signature, the handler conditionally moves that attempt to `signed` and durably records the -**complete signed response artifacts** — canonical encoded payloads, every signature, and any exact -signed transaction bytes plus nonce/hash/fee fields — before constructing the response. A retry with -the same idempotency key returns those exact stored artifacts without another KMS call only after -the canonical intent hash matches the marker; a mismatch is rejected. No signed response is returned -before that durable transition. If KMS or validation/encoding fails first, an idempotent compensation +a signature, the handler durably writes the **complete signed response artifacts** — canonical +encoded payloads, every signature, and any exact signed transaction bytes plus nonce/hash/fee fields +— as immutable artifact chunks keyed by `(attempt, artifact, chunk-index)`. Each chunk contains at +most **300 KiB** of binary content so item keys, checksums, and metadata remain below DynamoDB's +400 KB item limit. A manifest on the attempt records every artifact's ordered chunk count, byte +length, and whole-artifact checksum. Chunks are written and read back with checksum verification +before one conditional update installs the manifest and moves the attempt from `reserved` to +`signed`; incomplete unreferenced chunks are safe to garbage-collect after the attempt lease. A +retry with the same idempotency key returns those exact stored artifacts without another KMS call +only after the canonical intent hash matches the marker and every manifest chunk passes length and +checksum verification; a mismatch or missing chunk fails closed. No signed response is returned +before that durable transition. Boundary tests serialize the maximum 80-offer quote response, +including both canonical Mempool payload and signed transaction bytes, assert every stored item is +below 400 KB, then reassemble byte-for-byte identical artifacts from multiple chunks. If KMS or +validation/encoding fails first, an idempotent compensation transaction releases its exposure and signed-gas reservations, writes a terminal `failed` marker, and returns the typed failure. A retry with the same idempotency key observes that marker rather than releasing twice. A crash while the attempt is still `reserved` is reconciled by the same From c7b49227b0fc184c4bb807d1fe35b34152efaee1 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:03:58 +0000 Subject: [PATCH 24/37] docs(quoter-bot): close middleware review gaps --- ...08-12-quoter-bot-kms-signing-middleware.md | 109 +++++++++++------- 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 4b604035..4dd411d3 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -184,11 +184,15 @@ A break-glass revocation deliberately takes over the account's transaction strea incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It enumerates every occupied nonce from the middleware's recorded, still-pending routine transactions and signs a cleanup transaction at each **same nonce**, thereby replacing every unsafe publication -or ratification rather than queueing behind it. Each replacement fee bid exceeds the maximum -recorded fee fields across every routine signature at that nonce by at least the policy's 12.5% -replacement bump plus one wei, subject to the protected break-glass ceilings; the maximum rule is -defense in depth if the same-nonce routine fence was bypassed or older records predate it. The -runbook signs and broadcasts replacements for every occupied nonce in ascending order before +or ratification rather than queueing behind it. Each replacement fee bid uses the repository +replacement formula against the maximum recorded fee fields across every routine signature at that +nonce: the priority fee is bumped by 12.5% plus the one-wei floor, and `maxFeePerGas` is the maximum +of its corresponding bump and +`currentBaseFee * 2 + newPriorityFee`. The current pending-block base fee comes from an independent +RPC read immediately before signing, never from the caller. The result remains subject to the +protected break-glass ceilings; the maximum-recorded rule is defense in depth if the same-nonce +routine fence was bypassed or older records predate it. The runbook signs and broadcasts +replacements for every occupied nonce in ascending order before waiting for any replacement to confirm. This prevents a pending transaction at nonce N+1 from mining in the same block immediately after cleanup at nonce N. Only after the entire occupied prefix has a replacement in the network may the operator wait for confirmations and proceed with @@ -196,14 +200,18 @@ later, previously unused nonces. If the middleware cannot reconcile its record w pending transaction set, an occupied transaction was not recorded, or the protected ceilings cannot replace every occupied nonce, break-glass must use an ordered drain/handoff and must not claim that a next-unused-nonce revocation can preempt the pending stream. Routine transaction -ceilings must reserve one complete emergency bump for **both** fee fields: for each routine ceiling -`r`, the corresponding protected ceiling is at least -`max(floor(r * 1125 / 1000), r + 1 wei)`. Deployment validation rejects any weaker configuration, -and before returning every routine transaction (including a routine replacement) the middleware -reapplies the same formula to its actual recorded `maxFeePerGas` and `maxPriorityFeePerGas` and -requires both results to fit the protected ceilings. A middleware-produced routine bid therefore -cannot strand an otherwise valid emergency replacement merely because the ceilings were only one -wei apart. +ceilings must reserve one complete emergency bump for **both** fee fields. Deployment validation +requires the protected priority ceiling to cover +`max(floor(routinePriorityCeiling * 1125 / 1000), routinePriorityCeiling + 1 wei)` and the protected +max-fee ceiling to cover at least the corresponding bump of the routine max-fee ceiling. Because no +static ceiling can promise capacity across an unbounded future base-fee rise, readiness and the +break-glass preflight also read the current base fee and derive the exact emergency pair with the +same `currentBaseFee * 2 + newPriorityFee` floor. They fail closed before claiming replacement +capacity unless every derived fee fits the protected ceilings and the protected reserve can fund +every occupied nonce. Before returning every routine transaction (including a routine replacement), +the middleware performs that same live-base-fee preflight against its actual recorded fee fields. A +middleware-produced routine bid therefore cannot strand an otherwise valid emergency replacement +merely because the ceilings were only one wei apart or because the base-fee floor was omitted. Per-transaction fee/gas ceilings alone cannot stop a leaked invoker from bleeding the maker's native balance one valid cancellation at a time. Transaction-signing intents therefore also draw @@ -211,12 +219,16 @@ on **rolling signed-gas budgets** tracked in the reservation ledger (see Statefu class comes from the authenticated Lambda invoke surface, never from a caller-supplied principal, intent field, or `ClientContext`: publication, ratification, and the bot-only `routine-revoke` function draw on the routine budget, while the operator-only `break-glass-revoke` function alone -function draw on a **protected reserve** that the bot role has no IAM permission to invoke or draw +draws on a **protected reserve** that the bot role has no IAM permission to invoke or draw down. The primary protected reserve is sized for replacements at the **maximum configured occupied-nonce set** (each at the protected fee and gas ceilings) plus several full-book cleanups, with no overlap assumed between those costs. Deployment validation computes that bound from the -configured maximum and rejects a primary reserve below it. Both revoke functions use the same strict revoke -validator from the shared image, but their function ARNs, IAM grants, reserved concurrency, and server-side +configured maximum and rejects a primary reserve below it. The reservation transaction also counts +the maker's distinct non-terminal routine nonces and rejects any new-nonce signature that would +exceed that configured maximum; same-nonce replacements do not increase the count. This hard cap is +checked and updated atomically with nonce lease acquisition, so concurrent invocations cannot each +admit themselves against the last slot. Both revoke functions use the same strict revoke validator +from the shared image, but their function ARNs, IAM grants, reserved concurrency, and server-side budget classes are distinct. A compromised bot therefore cannot impersonate break-glass in its payload or starve the kill switch with otherwise valid cancellations. When the ledger is unavailable, quote/ratify and bot-originated routine revokes fail closed while the break-glass @@ -227,13 +239,16 @@ occupied-nonce set** (each at the protected fee and gas ceilings) plus one confi cleanup, with no overlap assumed between those costs. If the live occupied set exceeds that configured maximum, outage cleanup fails closed into ordered drain/handoff rather than claiming the ledger-outage guarantee. The same independent control plane keeps a -**ledger-independent revocation inventory**: an append-only, read-replicated catalog of every root -and group whose signature could make exposure publishable. Catalog persistence is a write-before- +**ledger-independent revocation inventory**: an append-only, strongly consistent catalog of every +root and group whose signature could make exposure publishable. Catalog persistence is a write-before- sign condition for quote and ratify: **both** the primary reservation and the independent catalog entry must commit durably in a `pending-signature` state before any KMS call. Failure of either write fails signing closed; the middleware never signs with uncharged aggregate capacity or an incomplete revocation inventory. After all signed response artifacts are durable, the finalization transaction -conditionally promotes the catalog entry to `signed`. The same idempotent compensation path that +atomically writes the ledger-independent transaction-inventory record and conditionally promotes the +catalog entry to `signed`; a `signed` entry can therefore never exist without the exact signed bytes, +nonce, hash, intent kind, and fee fields needed for cleanup. Stored-artifact retries become +deliverable only after that transaction commits. The same idempotent compensation path that releases a failed primary reservation also writes a terminal `failed` tombstone for its catalog entry. Break-glass enumerates only `signed` catalog entries; `pending-signature` entries past their short lease are reconciled to `signed` only from complete durable artifacts, otherwise they are @@ -241,14 +256,18 @@ tombstoned. If the catalog cannot prove an entry's terminal state, cleanup fails drain/handoff instead of charging emergency capacity for speculative targets. Break-glass therefore retains the targets needed to cancel hidden, signed-but-unpublished exposure even when the primary reservation ledger cannot be read. It also -keeps a **ledger-independent transaction inventory** of every returned routine transaction's exact -signed bytes, nonce, hash, intent kind, and fee fields. Persisting that record is a write-before- -return condition for every routine transaction; if the independent inventory cannot durably record -it, the routine signing request fails closed. During a primary-ledger outage, break-glass uses this -inventory to replace every occupied nonce with a fee bid derived from the recorded maximum. If the -inventory is unavailable or cannot account for the node's pending set, cleanup fails closed into the -ordered drain/handoff posture instead of claiming ledger-outage preemption. Reserved concurrency -isolates revoke capacity from quote floods but is explicitly **not** a rate limit; it cannot bound +keeps that **ledger-independent transaction inventory** for every signed routine transaction, +including one whose original Lambda response was not delivered. Persisting that record is a +write-before-`signed` and therefore write-before-return condition; if the independent inventory +cannot durably record it, finalization and every stored-artifact retry fail closed. During a +primary-ledger outage, break-glass uses this inventory to replace every occupied nonce with a fee +bid derived from the recorded maximum. The emergency reader uses strongly consistent or +quorum reads and verifies a writer-region high-watermark replicated after routine admission was +frozen; a stale or unverifiable watermark fails closed rather than treating a partial catalog as +complete. If the inventory is unavailable, stale, or cannot account for the node's pending set, +cleanup fails closed into the ordered drain/handoff posture instead of claiming ledger-outage +preemption. Reserved concurrency isolates revoke capacity from quote floods but is explicitly +**not** a rate limit; it cannot bound sequential signatures. If that independent budget cannot be checked atomically, revoke signing fails closed and pages the operator rather than becoming unmetered. The final backstop is a **native-balance funding ceiling** — a new operational control this TIB requires, distinct from @@ -400,10 +419,13 @@ surface's key or read the table. The setup/health execution role may write its o five exact keys, but may not alter the four signing-surface records; the bot role has no table access. Readiness resolves every production alias to its current published version and requires a fresh matching record for that exact version and manifest, so an attestation from a retired -deployment cannot satisfy the check. IAM grants only those per-key `dynamodb:PutItem` operations and -the setup role's bounded `GetItem`/`BatchGetItem`; no attestation path invokes a signing handler or -exposes a signing operation. Each published function version also exposes a dedicated **non-signing -attestation entrypoint** in the same image. After publishing a version and before moving its +deployment cannot satisfy the check. IAM grants only those per-key `dynamodb:PutItem` operations, +the setup role's bounded `GetItem`/`BatchGetItem`, and `lambda:GetAlias` on the five exact +production-alias ARNs (or an equivalently authenticated deployment manifest containing their exact +published-version targets); it grants no wildcard Lambda reads. The setup role resolves and records +those alias targets before accepting registry attestations. No attestation path invokes a signing +handler or exposes a signing operation. Each published function version also exposes a dedicated +**non-signing attestation entrypoint** in the same image. After publishing a version and before moving its production alias, deployment automation invokes that entrypoint under the function execution role; it performs only the key/config/image validation above and the conditional registry write. The deployment verifies the exact version-and-manifest record, retries transient failures, and refuses @@ -875,13 +897,20 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no budget, and the funding-ceiling alert when the maker's native balance exceeds its configured maximum. Queue routine publications at consecutive occupied nonces, then prove break-glass pre-signs and broadcasts replacements for every occupied nonce before waiting rather than signing - only the lowest or the next unused nonce. Prove primary-ledger outage cleanup uses the independent - transaction inventory's exact nonce and fee records; a missing or incoherent inventory forces - ordered handoff. Enter break-glass while routine-revoke is in flight and prove its generation lease - drains before cleanup and no new routine revoke can be signed until emergency deny is cleared. + only the lowest or the next unused nonce. Raise the base fee above the routine max-fee bump and + prove break-glass uses `baseFee * 2 + newPriorityFee`, then fails closed when that live result + exceeds the protected ceiling. Fill the configured occupied-nonce limit and prove a new-nonce + request is denied while a same-nonce replacement remains allowed; race requests for the final slot + and prove only one commits. Prove primary-ledger outage cleanup uses the independent transaction + inventory's exact nonce and fee records; a missing, incoherent, stale-replica, or lagging-watermark + inventory forces ordered handoff. Enter break-glass while routine-revoke is in flight and prove its + generation lease drains before cleanup and no new routine revoke can be signed until emergency deny + is cleared. - **Retry and delayed-broadcast safety:** drop the first Lambda response after the durable `signed` - transition and prove an idempotent retry returns byte-identical stored artifacts without a second - KMS call. Reuse that key with a different canonical intent and prove it returns a typed conflict, + transition and prove the transaction inventory already contains the exact artifact before an + idempotent retry returns byte-identical stored artifacts without a second KMS call. Inject an + inventory-write failure and prove neither the `signed` transition nor stored-artifact delivery is + possible. Reuse that key with a different canonical intent and prove it returns a typed conflict, no artifacts, and no KMS call. Race two routine signing intents at the same pending nonce and prove exactly one acquires the pre-sign nonce lease and reaches KMS. Withhold a signed transaction beyond its offer freshness expiry and prove its transaction @@ -892,8 +921,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned maker key, that the setup/health role cannot call `kms:Sign`, that each role can invoke only its granted surfaces, and that a break-glass principal is denied on setup/health, - quote, and ratify. Verify CloudTrail data events cover all five function ARNs. A denied call is part - of acceptance, not an incident. + quote, and ratify. Prove setup/health can call `lambda:GetAlias` on each of the five exact production + aliases, cannot call it on any other function or alias, and rejects an attestation whose published + version differs from the resolved alias target. Verify CloudTrail data events cover all five + function ARNs. A denied call is part of acceptance, not an incident. - Tests follow the repository verification rule: run each new test, break one assertion to confirm it fails, restore it. From 9261057d59c781116f74a21b68930c08471e0f38 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:29:06 +0000 Subject: [PATCH 25/37] docs(quoter-bot): clarify self-cancel replacement policy Exempt exact zero-value self-cancels from unchanged-payload constraints while pinning their safe transaction shape. --- ...IB-2026-08-12-quoter-bot-kms-signing-middleware.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 4dd411d3..d63b9607 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -165,10 +165,13 @@ chain. This same-nonce fence prevents a caller from withholding several alternat the one least favorable to break-glass cleanup. The fence has one routine liveness exception: the same authenticated surface may request a -**replacement** of its recorded, still-pending transaction. The middleware accepts only the exact -canonical intent and economic payload already recorded at that nonce, or an exact zero-value -self-cancel; the request cannot change offers, roots, groups, targets, calldata, value, or intent -kind. It reads the current pending-block base fee independently, derives +**replacement** of its recorded, still-pending transaction. The middleware accepts either the exact +canonical intent and economic payload already recorded at that nonce—with offers, roots, groups, +target, calldata, value, and intent kind unchanged—or an exact zero-value self-cancel at the same +nonce. The self-cancel is explicitly exempt from those unchanged-payload constraints and must instead +use the signer EOA as both sender and target, empty calldata, zero value, and the dedicated cancel +intent kind; it cannot carry offers, roots, groups, or any other economic action. It reads the current +pending-block base fee independently, derives `newPriorityFee = max(floor(previousPriorityFee * 1125 / 1000), previousPriorityFee + 1 wei)`, and derives `newMaxFee = max(floor(previousMaxFee * 1125 / 1000), previousMaxFee + 1 wei, currentBaseFee * 2 + newPriorityFee)`. From 4570ff024c99824e8ce8cc1dc53b8fc325cc490d Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:00:41 +0000 Subject: [PATCH 26/37] docs(quoter-bot): close KMS middleware policy gaps Make Setter cleanup authoritative, block signing on unspecified PnL/provider policy, and forbid weighted production aliases. --- ...08-12-quoter-bot-kms-signing-middleware.md | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index d63b9607..a2ad97be 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -133,10 +133,13 @@ chain id, a **per-operation target/selector and calldata allowlist** (group cons pinned as `onBehalf`; Ecrecover root cancellation is exactly `cancelRoot(maker, root)` on the configured ratifier; and Setter root cancellation is exactly `setIsRootRatified(maker, root, false)` on the configured `SetterRatifier`), zero native value, -and fee/gas ceilings. Root cancellation is therefore available for both ratifier families rather -than forcing Setter deployments to cancel every group individually. This mirrors the constraint -set the quoter's in-process transaction assertions already pin, now enforced outside the bot; a -compromised revoke invoker cannot spend gas mutating another maker's groups or roots. +and fee/gas ceilings. For Setter deployments, `false` is defense in depth only: routine and +break-glass cleanup must drain or replace every already-signed `true` ratification transaction and +irreversibly consume every affected offer group before reporting success. A withheld older `true` +can otherwise restore the mutable root flag. Ecrecover root cancellation remains authoritative on +its own. This mirrors the constraint set the quoter's in-process transaction assertions already +pin, now enforced outside the bot; a compromised revoke invoker cannot spend gas mutating another +maker's groups or roots. Maker-wide cleanup and startup cleanup keep the existing batch behavior exposed by `OfferInvalidationPort.invalidateBatch`: the revoke surface may encode one Midnight `multicall`, @@ -307,8 +310,11 @@ The set is approved only when **three properties** all hold: of the middleware's own deployment configuration — never supplied per-request. 3. **No PnL drop.** Publishing the offers must not degrade the maker address's PnL: offer prices must remain sustainable, i.e. a fill at the offered price must not realize a loss against the - maker's position/cost basis. The exact PnL/cost-basis model and the independent data it needs - are open questions; the property itself is a decided policy requirement. + maker's position/cost basis. The exact PnL/cost-basis model and its independent data sources are + a blocking v0 design deliverable, not an implementation choice: quote and ratify signing remain + disabled until that model is specified, reviewed, encoded in deployment policy, and covered by + acceptance tests that prove profitable, boundary, and loss-making fills are classified as + specified from independently read inputs. Beneath these three headline properties, **field-level validation** on every offer: market allowlist; per-market and total lend-exposure caps for **exposure-increasing buy offers**, enforced @@ -420,9 +426,13 @@ function-and-published-version key in a dedicated DynamoDB table; the value cont fields above, the deployment-manifest digest, and a startup timestamp. It cannot write another surface's key or read the table. The setup/health execution role may write its own key and read all five exact keys, but may not alter the four signing-surface records; the bot role has no table -access. Readiness resolves every production alias to its current published version and requires a -fresh matching record for that exact version and manifest, so an attestation from a retired -deployment cannot satisfy the check. IAM grants only those per-key `dynamodb:PutItem` operations, +access. Readiness resolves every production alias to its current published version, requires +`RoutingConfig.AdditionalVersionWeights` to be empty, and requires a fresh matching record for that +exact version and manifest. Weighted/canary routing is forbidden for the five production aliases in +v0, so an unattested additional version cannot receive signing traffic and an attestation from a +retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed +when any production alias has additional version weights. IAM grants only those per-key +`dynamodb:PutItem` operations, the setup role's bounded `GetItem`/`BatchGetItem`, and `lambda:GetAlias` on the five exact production-alias ARNs (or an equivalently authenticated deployment manifest containing their exact published-version targets); it grants no wildcard Lambda reads. The setup role resolves and records @@ -725,11 +735,12 @@ host itself; it strengthens rather than changes this design. transaction serialization). - The policy surface splits cleanly: price bounds and field pins are **deployment parameters**; the crossed-book and no-PnL-drop properties are evaluated against the Lambda's **own - independent reads** (RPC and Morpho API/Mempool). The Lambda has network egress to those - sources, and a failed read fails closed. The RPC reads need **strong resilience — fallback - providers and/or quorum agreement** — because a single lying or censoring provider is the - remaining way to get a bad set past those two checks (open question 7). Reads for one intent - are pinned to a single deterministic snapshot; a mixed-block view is a denial, not an input. + independent reads** (RPC and Morpho API/Mempool). The Lambda has network egress to a reviewed, + deployment-pinned provider set. v0 must specify that exact set, its quorum threshold, and + normalization rules; every required policy value must reach quorum for the same deterministic + snapshot. A failed read, insufficient quorum, provider disagreement, or mixed-block view is a + typed denial with no KMS call. Quote and ratify signing remain disabled until this provider and + fail-closed disagreement policy is configured and covered by acceptance tests. - DynamoDB is available to the Lambda for the reservation ledger and invoke-surface-and-intent-keyed signed-gas budgets. Middleware mode enforces the 40-rung-per-side cap so every reservation plan fits one 100-action transaction; configuration and runtime guards @@ -765,6 +776,13 @@ host itself; it strengthens rather than changes this design. `ListTakeableOfferResponse` exposes only `cursor` and `data`, so adding and consuming this metadata is a blocking v0 deliverable; middleware quote/ratify signing must remain disabled until an integration test proves all policy reads can be pinned to one indexed block. +- A reviewed PnL/cost-basis specification and independent input contract, implemented as deployment + policy with acceptance vectors for profitable, exact-boundary, and loss-making fills. This is a + blocking v0 deliverable; quote/ratify signing remains disabled until those tests pass. +- A deployment-pinned independent-read provider set with an explicit quorum threshold and + fail-closed disagreement/normalization policy. This is a blocking v0 deliverable; quote/ratify + signing remains disabled until integration tests prove disagreement, insufficient quorum, and + mixed-snapshot responses produce no KMS call. - DynamoDB for the reservation ledger and signed-gas budget, with conditional writes and the middleware-only rung cap that keeps each reservation within one transaction. - `@morpho-org/midnight-sdk` offer-tree EIP-712 hashing for canonical encoding inside the Lambda. @@ -877,7 +895,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no pass the same target/maker/cap checks, with nested/empty/foreign-selector batches denied, a routine signed-gas budget refusing the next publication while the revoke reserve still signs, both/neither of `maxUnits`/`maxAssets` set, - off-by-one exposure caps, a prospective set that crosses only in combination with live offers. + off-by-one exposure caps, a prospective set that crosses only in combination with live offers, + PnL vectors for profitable, exact-boundary, and loss-making fills using only the specified + independent cost-basis inputs, and provider disagreement or insufficient quorum denied with no + KMS call. - **Adversarial state:** intents accompanied by caller-supplied book or position state that contradicts chain truth — the Lambda must ignore the caller's view entirely and decide from its own reads. @@ -925,9 +946,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no maker key, that the setup/health role cannot call `kms:Sign`, that each role can invoke only its granted surfaces, and that a break-glass principal is denied on setup/health, quote, and ratify. Prove setup/health can call `lambda:GetAlias` on each of the five exact production - aliases, cannot call it on any other function or alias, and rejects an attestation whose published - version differs from the resolved alias target. Verify CloudTrail data events cover all five - function ARNs. A denied call is part of acceptance, not an incident. + aliases, cannot call it on any other function or alias, rejects an attestation whose published + version differs from the resolved alias target, and fails readiness when + `RoutingConfig.AdditionalVersionWeights` is non-empty. Prove deployment automation refuses a + weighted production-alias rollout. Verify CloudTrail data events cover all five function ARNs. A + denied call is part of acceptance, not an incident. - Tests follow the repository verification rule: run each new test, break one assertion to confirm it fails, restore it. @@ -951,8 +974,9 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no 2. Whether validation logic is shared with bot domain code (one bug affects both — `@repo/offers` is the natural shared home for the crossed-book model) or independently implemented (drift risk) — likely a shared schema, independently pinned middleware deployments. -3. The exact PnL/cost-basis model the Lambda evaluates for the no-PnL-drop property, and the - independent data sources it needs. +3. The exact PnL/cost-basis model and independent input contract. This must be specified, reviewed, + and acceptance-tested before v0 quote/ratify signing can be enabled; it is not left to the + implementer. 4. Whether setup-remediation transactions (token approvals) are also gated through the middleware or stay manual operator actions. Setter root approvals are decided: they are the ratify intent. @@ -964,8 +988,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no high-availability independent emergency-budget store used during a ledger outage. Every outage revoke must atomically consume that durable allowance or fail closed; reserved concurrency is only availability isolation and never substitutes for budget accounting. -7. Lambda networking/egress design for its independent RPC and Morpho API/Mempool reads — and the - decision posture when those providers disagree with the bot's view of the book. +7. The exact deployment-pinned provider members and quorum threshold for independent RPC and Morpho + API/Mempool reads. The decision posture is settled: disagreement, insufficient quorum, or an + incoherent snapshot fails closed with no KMS call, and quote/ratify remains disabled until this + configuration and its integration tests are complete. ## References From 7ea28cf19a843dd4237f2d24b8b99c7eb38e64c0 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:37:32 +0000 Subject: [PATCH 27/37] docs(quoter-bot): address KMS middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index a2ad97be..a83055bb 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -180,11 +180,16 @@ derives `newMaxFee = max(floor(previousMaxFee * 1125 / 1000), previousMaxFee + 1 wei, currentBaseFee * 2 + newPriorityFee)`. This is the repository fee policy, including its base-fee floor rather than only the replacement bump. The middleware applies the routine ceilings and rolling signed-gas budget to both derived -fields and atomically replaces the recorded fee fields, signed bytes, and hash before returning the -new artifact. Repeated bumps therefore restore a path for an underpriced transaction without -creating a menu of economically different same-nonce signatures. A replacement that cannot read -the current base fee, be recorded, budgeted, or be reconciled with the pending transaction fails -closed. +fields. Before returning, it appends the new fee fields, signed bytes, and hash to the nonce's +durable artifact history and atomically marks that entry as the latest active artifact. Same-nonce +replacement history is append-only: no bump or self-cancel overwrites an older artifact, because +every previously returned byte string remains broadcastable until the nonce is confirmed or +replaced/cancelled on chain. Routine retries and later bumps use the latest active artifact, while +budgeting and break-glass cleanup retain every artifact and use the maximum fee fields across the +complete history at that nonce. Repeated bumps therefore restore a path for an underpriced +transaction without losing earlier signed exposure. A replacement that cannot read the current +base fee, append and activate its record atomically, be budgeted, or be reconciled with the pending +transaction fails closed. A break-glass revocation deliberately takes over the account's transaction stream during an incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It @@ -291,9 +296,14 @@ first. A replacement therefore sequences exactly as the in-process `MakeService` request the revoke signature, broadcast it, wait until the middleware's snapshot shows the old groups gone, then request the quote signature against the now-clean book. A replacement whose transitional old-plus-new book stays fully in-policy may skip the wait and revoke after -publication. An approved quote intent returns both signed artifacts the publication flow needs: -the EIP-712 tree signature (Ecrecover) and the signed zero-value publication transaction to the -Midnight Mempool contract, whose calldata the middleware itself encoded from the validated set. +publication. For Ecrecover, the quote intent returns the tree signature and encoded publication +payload; the same constrained non-maker publication-broadcaster used by Setter submits that exact +zero-value payload to the Midnight Mempool contract. The maker KMS does not sign a second EOA +publication transaction. For Setter, the ratify intent returns the maker-signed ratification +transaction plus the independently encoded publication payload described below. In both modes the +middleware encodes the payload from the validated set, and the minimally funded non-maker sender +adds no authorization or policy decision. + The set is approved only when **three properties** all hold: 1. **No crossed books.** The prospective offer set — observed live offers, every still-outstanding @@ -476,22 +486,25 @@ logic, and any alternative host must preserve the trust split: the middleware al function is the authenticated signer-identity setup port used by readiness; it can return only the validated setup fields described above and has no signing-intent handler. - **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are - `lambda:InvokeFunction` on setup/health and its routine intent surfaces — the bot loses `kms:Sign` - and `kms:GetPublicKey` entirely. The four signing functions' execution roles are the only + `lambda:InvokeFunction` on the exact production-alias ARNs for setup/health and its routine intent + surfaces — never an unqualified function ARN, a version ARN, `$LATEST`, or another alias. The bot + loses `kms:Sign` and `kms:GetPublicKey` entirely. Operator grants are likewise restricted to the + exact break-glass production-alias ARN. The four signing functions' execution roles are the only principals with `kms:Sign`; all five execution roles have narrowly scoped `kms:GetPublicKey` on that same maker key solely for their startup attestation, while setup/health has no `kms:Sign`. Creating those execution roles, the invoke-only credentials, and the CloudTrail data-event selectors for all five functions (see Observability) are part of the deliverable. -- **Caller-to-surface scoping**: principals are authorized per function ARN. The three structured +- **Caller-to-surface scoping**: principals are authorized per exact production-alias ARN. The three structured intent types map to **four signing Lambda functions**, while setup/readiness maps to the fifth setup/health function — one shared container image, five deployments — because routine and break-glass revoke must be distinguishable by an authenticated AWS boundary, not by untrusted payload data. Aliases are not enough: reserved concurrency is function-scoped, and distinct functions also let IAM deny the bot access to the protected reserve. IAM grants the - bot setup/health, quote, ratify, and routine-revoke only; break-glass principals receive - break-glass-revoke only. Each signing handler pins its intent type and budget class in deployment - configuration before + bot setup/health, quote, ratify, and routine-revoke production aliases only; break-glass principals + receive only the break-glass-revoke production alias. Explicit denies and acceptance tests cover + unqualified and `$LATEST` invokes, every non-production alias, and cross-surface aliases. Each + signing handler pins its intent type and budget class in deployment configuration before calling the shared validator. It never reads a claimed principal or budget class from the intent payload or `ClientContext`. Leaked break-glass credentials must yield revocations, never signed quotes or ratifications, while leaked bot credentials can never consume emergency capacity. @@ -508,8 +521,9 @@ logic, and any alternative host must preserve the trust split: the middleware al are settled at implementation (open question 1). - Flow: the bot builds the desired offer array → invokes the Lambda with the structured intent → the Lambda validates the three properties plus field checks, canonically encodes, derives the - digest, calls KMS → returns the signatures and encoded payloads (tree plus publication - transaction for quotes). Sign-what-you-encode is unchanged by the deployment shape. + digest, calls KMS → returns the required maker authorization and encoded publication payload + (an Ecrecover tree signature or a Setter ratification transaction, never a maker-signed Ecrecover + publication transaction). Sign-what-you-encode is unchanged by the deployment shape. ### 5. Availability posture @@ -939,13 +953,19 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no exactly one acquires the pre-sign nonce lease and reaches KMS. Withhold a signed transaction beyond its offer freshness expiry and prove its transaction record, nonce fence, and signed-gas reservation remain until that nonce is confirmed or replaced/ - cancelled on chain. + cancelled on chain. Sign an economic transaction, then create a same-nonce bump and a self-cancel; + prove the independent inventory retains every hash, byte string, and fee pair append-only, marks + only the newest entry active for routine replacement, and derives break-glass fees from the maximum + across all three artifacts. - **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned maker key, that the setup/health role cannot call `kms:Sign`, that each role can - invoke only its granted surfaces, and that a break-glass principal is denied on setup/health, - quote, and ratify. Prove setup/health can call `lambda:GetAlias` on each of the five exact production + invoke only its granted production-alias surfaces, and that a break-glass principal is denied on + setup/health, quote, and ratify. For every bot and operator surface, prove the exact production alias + succeeds while the unqualified function ARN, `$LATEST`, every other version/alias, and every + cross-surface production alias receive `AccessDenied`. Prove setup/health can call + `lambda:GetAlias` on each of the five exact production aliases, cannot call it on any other function or alias, rejects an attestation whose published version differs from the resolved alias target, and fails readiness when `RoutingConfig.AdditionalVersionWeights` is non-empty. Prove deployment automation refuses a From 0b1e4378e3778f463a882bbc904fdad194a4eae0 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:59:35 +0000 Subject: [PATCH 28/37] docs(quoter-bot): resolve middleware review findings Correct Ecrecover KMS audit expectations, define a gated setup-remediation signing surface, and attest each surface-specific configuration independently. --- ...08-12-quoter-bot-kms-signing-middleware.md | 138 +++++++++++------- 1 file changed, 84 insertions(+), 54 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index a83055bb..ff4a465c 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -39,7 +39,8 @@ AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addre - Make the signing middleware the **only principal** allowed to call `kms:Sign` on the maker key. The bot loses direct KMS access entirely; its AWS role is reduced to invoking the middleware. -- Replace blind digest signing with **structured intents** (revoke, quote, ratify) that the middleware +- Replace blind digest signing with **structured intents** (revoke, quote, ratify, and + setup-remediation) that the middleware validates against policy it owns — bounds and pins from its own deployment parameters, book and position state from its own independent reads. Nothing policy-relevant comes from the request. - **Sign-what-you-encode**: the middleware canonically encodes each validated intent and derives @@ -288,6 +289,20 @@ exceeds the maximum. Gas grief can never exceed the remaining routine budget plu emergency allowance, and never exceed what is funded. Native-gas spend is thereby capped and enters the bounded-loss arithmetic. +**Setup-remediation intents** replace the maker's direct KMS path for token approvals and other +setup transactions. They are accepted only by a dedicated operator-authorized function while a +maintenance cleanup epoch has stopped quote, ratify, and routine-revoke signing. The deployment +manifest pins every permitted target, selector, asset, spender/operator, allowance or authorization +ceiling, chain, zero native value, and gas/fee ceiling; the middleware independently reads current +allowance/authorization state and canonically encodes the exact transaction. Arbitrary calldata, +permit signatures, asset transfers, wildcard spenders/operators, and caller-selected targets are +rejected. Setup remediation uses the same nonce lease, append-only artifact history, rolling gas +accounting, replacement rules, and break-glass preemption guarantees as every other maker +transaction. Its invoke role is separate from both the bot and break-glass roles, and neither role +can invoke it. Direct bot or operator access to `kms:Sign` may not be removed until this surface is +deployed and its positive and deny-path acceptance tests pass; after cutover, no manual remediation +procedure may restore direct KMS signing. + **Quote intents** carry an array of structured offers. There are **no caller-declared exclusions**: the prospective book is always the observed live book plus the proposed set, because a promised-but-unobserved invalidation is worth nothing at the signing boundary — a @@ -407,9 +422,10 @@ signed, by construction. The viem `LocalAccount.sign(hash)` blind-digest surface is exactly what is being removed, so the middleware is deliberately **not** a drop-in `LocalAccount` replacement. The bot-side seam is -intent-level ports — a quote-publication port (signed tree plus signed publication transaction), -an invalidation-signing port, a root-ratification port for Setter deployments, and the constrained -non-maker publication-broadcaster port described above — backed by middleware-invoking adapters, +intent-level ports — a quote-publication port (Ecrecover tree signature plus encoded publication +payload), an invalidation-signing port, a root-ratification port for Setter deployments, a +setup-remediation port, and the constrained non-maker publication-broadcaster port described above +— backed by middleware-invoking adapters, selected as a new identity method alongside `private-key`/`keystore`/`aws` in [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any @@ -417,11 +433,15 @@ residual generic digest-signing path fails closed. Middleware mode also adds an authenticated **signer-identity setup port**. Every function execution role, not the bot role, may call `kms:GetPublicKey` only on the configured maker key. At cold start -and before serving, each of the five deployments derives the secp256k1 address and key fingerprint, -computes its effective policy/configuration digest, and fails closed unless all three equal the -expected deployment manifest. Setup/health aggregates those per-surface attestations and is ready -only when setup, quote, ratify, routine-revoke, and break-glass-revoke report the same maker, chain, -key fingerprint, image digest, and policy/configuration digest. A correct setup function therefore +and before serving, each of the six deployments derives the secp256k1 address and key fingerprint, +computes its surface-specific policy/configuration digest, and fails closed unless the shared +identity fields and that surface's digest equal their separate expected values in the deployment +manifest. Setup/health aggregates those per-surface attestations and is ready only when setup, +quote, ratify, routine-revoke, break-glass-revoke, and setup-remediation report the same maker, +chain, key fingerprint, image digest, and deployment-manifest digest, and each reports its own +manifest-pinned surface-specific policy/configuration digest. Surface digests are intentionally not +required to equal one another: each signing handler pins a different intent type, invoke principal, +and budget class. A correct setup function therefore cannot mask a stale key or policy on a signing surface. The setup port returns only those validated fields through the IAM-authenticated invocation response — no public key, signature, digest-signing primitive, or caller-selected challenge. `SetupStateService` obtains its derived-maker observation @@ -433,17 +453,18 @@ the bot no longer has direct KMS access. The aggregation path is an internal attestation registry, not an assertion synthesized by setup/health. After validating itself, each execution role may conditionally write only its own function-and-published-version key in a dedicated DynamoDB table; the value contains the validated -fields above, the deployment-manifest digest, and a startup timestamp. It cannot write another -surface's key or read the table. The setup/health execution role may write its own key and read all -five exact keys, but may not alter the four signing-surface records; the bot role has no table +shared fields, that surface's policy/configuration digest, the deployment-manifest digest, and a +startup timestamp. It cannot write another surface's key or read the table. The setup/health +execution role may write its own key and read all six exact keys, but may not alter the five +signing-surface records; the bot role has no table access. Readiness resolves every production alias to its current published version, requires `RoutingConfig.AdditionalVersionWeights` to be empty, and requires a fresh matching record for that -exact version and manifest. Weighted/canary routing is forbidden for the five production aliases in +exact version and manifest. Weighted/canary routing is forbidden for the six production aliases in v0, so an unattested additional version cannot receive signing traffic and an attestation from a retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed when any production alias has additional version weights. IAM grants only those per-key `dynamodb:PutItem` operations, -the setup role's bounded `GetItem`/`BatchGetItem`, and `lambda:GetAlias` on the five exact +the setup role's bounded `GetItem`/`BatchGetItem`, and `lambda:GetAlias` on the six exact production-alias ARNs (or an equivalently authenticated deployment manifest containing their exact published-version targets); it grants no wildcard Lambda reads. The setup role resolves and records those alias targets before accepting registry attestations. No attestation path invokes a signing @@ -479,42 +500,47 @@ an HTTP API, a Cloudflare Worker — replaces the handler and the bot-side adapt logic, and any alternative host must preserve the trust split: the middleware alone holds `kms:Sign`, and callers hold nothing but the right to invoke it. -- The middleware is a set of **five AWS Lambda functions — setup/health, quote, ratify, routine - revoke, and break-glass revoke — built from one shared container image** and invoked through the AWS SDK +- The middleware is a set of **six AWS Lambda functions — setup/health, quote, ratify, routine + revoke, break-glass revoke, and setup remediation — built from one shared container image** and + invoked through the AWS SDK (`lambda:InvokeFunction`). Routine and break-glass revoke deliberately have separate authenticated - invoke surfaces even though they enforce the same structured revoke intent. The setup/health - function is the authenticated signer-identity setup port used by readiness; it can return only the + invoke surfaces even though they enforce the same structured revoke intent. Setup remediation is + a separate operator-only surface whose strict transaction allowlist cannot be selected by bot or + break-glass payload data. The setup/health function is the authenticated signer-identity setup + port used by readiness; it can return only the validated setup fields described above and has no signing-intent handler. - **IAM chain**: the bot's AWS credentials attach to a role whose only permissions are `lambda:InvokeFunction` on the exact production-alias ARNs for setup/health and its routine intent surfaces — never an unqualified function ARN, a version ARN, `$LATEST`, or another alias. The bot loses `kms:Sign` and `kms:GetPublicKey` entirely. Operator grants are likewise restricted to the - exact break-glass production-alias ARN. The four signing functions' execution roles are the only - principals with `kms:Sign`; all five execution roles have narrowly scoped `kms:GetPublicKey` on + exact break-glass production-alias ARN. The five signing functions' execution roles are the only + principals with `kms:Sign`; all six execution roles have narrowly scoped `kms:GetPublicKey` on that same maker key solely for their startup attestation, while setup/health has no `kms:Sign`. Creating those execution roles, the invoke-only credentials, and - the CloudTrail data-event selectors for all five functions (see Observability) are part of the + the CloudTrail data-event selectors for all six functions (see Observability) are part of the deliverable. -- **Caller-to-surface scoping**: principals are authorized per exact production-alias ARN. The three structured - intent types map to **four signing Lambda functions**, while setup/readiness maps to the fifth - setup/health function — one shared container image, five deployments — +- **Caller-to-surface scoping**: principals are authorized per exact production-alias ARN. The four + structured intent types map to **five signing Lambda functions**, while setup/readiness maps to the + sixth setup/health function — one shared container image, six deployments — because routine and break-glass revoke must be distinguishable by an authenticated AWS boundary, not by untrusted payload data. Aliases are not enough: reserved concurrency is function-scoped, and distinct functions also let IAM deny the bot access to the protected reserve. IAM grants the bot setup/health, quote, ratify, and routine-revoke production aliases only; break-glass principals - receive only the break-glass-revoke production alias. Explicit denies and acceptance tests cover + receive only the break-glass-revoke production alias; remediation operators receive only the + setup-remediation production alias. Explicit denies and acceptance tests cover unqualified and `$LATEST` invokes, every non-production alias, and cross-surface aliases. Each signing handler pins its intent type and budget class in deployment configuration before calling the shared validator. It never reads a claimed principal or budget class from the intent payload or `ClientContext`. Leaked break-glass credentials must yield revocations, never signed - quotes or ratifications, while leaked bot credentials can never consume emergency capacity. + quotes, ratifications, or setup approvals, while leaked bot credentials can never consume + emergency or remediation capacity. - Authentication is therefore **IAM/SigV4** — no self-managed ingress, tokens, or mTLS. The in-policy guarantee still does not depend on caller identity — any invoker only ever obtains in-policy signatures — while the caller-to-surface scoping above decides _which_ in-policy intents a given principal may submit, and invoke scoping keeps revoke-griefing/DoS hard and the audit trail attributable. - The middleware's **code lives in this monorepo** and deploys as one **Docker container image** - (ECR-hosted) instantiated as the five functions, with its own Dockerfile, like the + (ECR-hosted) instantiated as the six functions, with its own Dockerfile, like the bots. It is not a bot — not a long-running program — so it does not live under `/bots/`; the proposed workspace home is a new top-level `services/` directory, e.g. `services/quoter-signer`. Final naming and location @@ -739,11 +765,12 @@ host itself; it strengthens rather than changes this design. ## Assumptions & Constraints - IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly - setup/health, quote, ratify, and routine-revoke and nothing else; the four signing Lambda - execution roles are the sole `kms:Sign` principals on the maker key, while all five function + setup/health, quote, ratify, and routine-revoke and nothing else; the five signing Lambda + execution roles are the sole `kms:Sign` principals on the maker key, while all six function roles hold narrowly scoped `kms:GetPublicKey` solely for per-surface startup attestation; - break-glass operators hold only the separate break-glass-revoke invoke grant. Policy selects the - budget from that fixed function deployment, never from caller-controlled request data. + break-glass operators hold only the separate break-glass-revoke invoke grant, and remediation + operators hold only the setup-remediation invoke grant. Policy selects the budget from that fixed + function deployment, never from caller-controlled request data. - Every signed payload class is fully describable as structured intents and canonically encodable inside the Lambda (SDK EIP-712 offer-tree hashing and Mempool payload encoding; viem transaction serialization). @@ -808,19 +835,21 @@ host itself; it strengthens rather than changes this design. - The Lambda emits the same JSON-lines structured logging the bots use (to CloudWatch Logs). Every intent produces a decision event with intent type, evaluated properties and constraints, - the violated check on denial, and the **expected KMS call set per signed artifact** — an - approved Ecrecover quote legitimately produces two `kms:Sign` calls (tree and publication - transaction), each logged with its derived digest, the **KMS request ID** returned with the + the violated check on denial, and the **expected KMS call set per signed artifact**. An + approved Ecrecover quote produces exactly one `kms:Sign` call for the tree signature; its encoded + publication payload is sent by the constrained non-maker broadcaster and never creates a maker + KMS call. Setter ratification, revoke, and setup-remediation transactions each produce one call + per signed transaction artifact. Every call is logged with its derived digest, the **KMS request ID** returned with the signature, and outcome: `middleware.intent_received`, `middleware.intent_approved`, `middleware.intent_denied`, `middleware.kms_error`, `middleware.read_failed`. - This log is an **authorization audit trail that survives bot-host compromise** — the bot cannot erase or forge it. -- CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot vs - break-glass principals), and `kms:Sign` allows exactly the configured **quote, ratify, - routine-revoke, and break-glass-revoke execution-role ARNs**. The reconciler maintains that - four-role allowlist and maps each role ARN to its one expected intent surface; a call from an - unknown role or a known role signing for another surface is an incident. The KMS call stream for +- CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot, + break-glass, or remediation principal), and `kms:Sign` allows exactly the configured **quote, + ratify, routine-revoke, break-glass-revoke, and setup-remediation execution-role ARNs**. The + reconciler maintains that five-role allowlist and maps each role ARN to its one expected intent + surface; a call from an unknown role or a known role signing for another surface is an incident. The KMS call stream for each role/surface pair must reconcile against the logged per-artifact signing records. CloudTrail `Sign` events do not carry the message or digest, so the join key is the **KMS request ID** each signing record captures: every CloudTrail `Sign` event must match one middleware record and @@ -828,7 +857,7 @@ host itself; it strengthens rather than changes this design. side is an incident signal. Lambda `Invoke` is a CloudTrail **data event and is not logged by default** ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); - enabling data-event selectors for all five function ARNs is an explicit v0 deliverable — + enabling data-event selectors for all six function ARNs is an explicit v0 deliverable — without complete coverage, the invoke side of this audit trail silently omits intent surfaces. - Alerting on denials, invocation errors/throttles, KMS errors, and independent-read failures. Bot-side `make.rejected` events extend with middleware-denial reasons; invocation-failure halts @@ -922,8 +951,9 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no - **Signature correctness:** recovered signer equals the configured maker across both recovery parities, reusing the existing strict DER/low-s/recovery-check discipline. The authenticated signer-identity setup port reports the KMS-derived maker and passes readiness only when every - deployed surface attests the same endpoint, chain, key fingerprint, image digest, policy digest, - and configured maker; drift on any one signing function fails closed without exposing a generic + deployed surface attests the same endpoint, chain, key fingerprint, image digest, + deployment-manifest digest, and configured maker, and each surface's distinct configuration + digest matches its own manifest entry; drift on any one signing function fails closed without exposing a generic challenge-signing surface. - **Fail-closed negatives:** generic digest-signing requests are rejected; unknown intent versions are rejected; an independent-read failure produces a typed denial and no KMS call; a @@ -961,15 +991,19 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned maker key, that the setup/health role cannot call `kms:Sign`, that each role can - invoke only its granted production-alias surfaces, and that a break-glass principal is denied on - setup/health, quote, and ratify. For every bot and operator surface, prove the exact production alias + invoke only its granted production-alias surfaces, that a break-glass principal is denied on + setup/health, quote, ratify, and setup-remediation, and that the remediation principal is denied on + every non-remediation surface. Prove a permitted setup approval is canonically encoded and signed + only during a maintenance cleanup epoch, while foreign targets/spenders, excessive allowance, + transfers, permits, non-zero value, and attempts outside the epoch produce no KMS call. For every + bot and operator surface, prove the exact production alias succeeds while the unqualified function ARN, `$LATEST`, every other version/alias, and every cross-surface production alias receive `AccessDenied`. Prove setup/health can call - `lambda:GetAlias` on each of the five exact production + `lambda:GetAlias` on each of the six exact production aliases, cannot call it on any other function or alias, rejects an attestation whose published version differs from the resolved alias target, and fails readiness when `RoutingConfig.AdditionalVersionWeights` is non-empty. Prove deployment automation refuses a - weighted production-alias rollout. Verify CloudTrail data events cover all five function ARNs. A + weighted production-alias rollout. Verify CloudTrail data events cover all six function ARNs. A denied call is part of acceptance, not an incident. - Tests follow the repository verification rule: run each new test, break one assertion to confirm it fails, restore it. @@ -979,8 +1013,7 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no - Nitro-Enclave attestation binding the KMS key policy to attested signing code (Alternative 7) as host hardening. - Generalizing to other bots and keys — multi-tenant policy keyed by principal and key. -- Gating setup-remediation transactions (token approvals) if they move from manual operator - actions to automated flows (open question 4). + - When the on-chain bounded ratifier lands, its bounds and the middleware's price policy should agree; the middleware remains necessary for the transaction surface. - Middleware-direct publication to the Mempool, or ratifier/Mempool-enforced signature freshness, @@ -997,18 +1030,15 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no 3. The exact PnL/cost-basis model and independent input contract. This must be specified, reviewed, and acceptance-tested before v0 quote/ratify signing can be enabled; it is not left to the implementer. -4. Whether setup-remediation transactions (token approvals) are also gated through the middleware - or stay manual operator actions. Setter root approvals are decided: they are the ratify - intent. -5. Policy parameter change/approval workflow — who reviews, how it deploys, how changes are +4. Policy parameter change/approval workflow — who reviews, how it deploys, how changes are audited. -6. The reservation ledger's concrete store and consistency design — DynamoDB conditional writes +5. The reservation ledger's concrete store and consistency design — DynamoDB conditional writes are the default candidate — including release on observed publication/invalidation, expiry eviction, per-surface budget partitioning details, and the independently operated, high-availability independent emergency-budget store used during a ledger outage. Every outage revoke must atomically consume that durable allowance or fail closed; reserved concurrency is only availability isolation and never substitutes for budget accounting. -7. The exact deployment-pinned provider members and quorum threshold for independent RPC and Morpho +6. The exact deployment-pinned provider members and quorum threshold for independent RPC and Morpho API/Mempool reads. The decision posture is settled: disagreement, insufficient quorum, or an incoherent snapshot fails closed with no KMS call, and quote/ratify remains disabled until this configuration and its integration tests are complete. From c8ec62bc90f92c36ba8faed1d2567e8549e3a995 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:32:45 +0000 Subject: [PATCH 29/37] docs(quoter-bot): resolve middleware security findings --- ...08-12-quoter-bot-kms-signing-middleware.md | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index ff4a465c..7091099e 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -194,11 +194,12 @@ transaction fails closed. A break-glass revocation deliberately takes over the account's transaction stream during an incident. It does **not** sign the node's `pending` nonce, which is the next unused nonce. It -enumerates every occupied nonce from the middleware's recorded, still-pending routine transactions -and signs a cleanup transaction at each **same nonce**, thereby replacing every unsafe publication -or ratification rather than queueing behind it. Each replacement fee bid uses the repository -replacement formula against the maximum recorded fee fields across every routine signature at that -nonce: the priority fee is bumped by 12.5% plus the one-wei floor, and `maxFeePerGas` is the maximum +enumerates every occupied nonce from the middleware's recorded, still-pending routine and +setup-remediation transactions and signs a cleanup transaction at each **same nonce**, thereby +replacing every unsafe publication, ratification, or remediation rather than queueing behind it. +Each replacement fee bid uses the repository replacement formula against the maximum recorded fee +fields across every signature at that nonce: the priority fee is bumped by 12.5% plus the one-wei +floor, and `maxFeePerGas` is the maximum of its corresponding bump and `currentBaseFee * 2 + newPriorityFee`. The current pending-block base fee comes from an independent RPC read immediately before signing, never from the caller. The result remains subject to the @@ -257,10 +258,16 @@ sign condition for quote and ratify: **both** the primary reservation and the in entry must commit durably in a `pending-signature` state before any KMS call. Failure of either write fails signing closed; the middleware never signs with uncharged aggregate capacity or an incomplete revocation inventory. After all signed response artifacts are durable, the finalization transaction -atomically writes the ledger-independent transaction-inventory record and conditionally promotes the -catalog entry to `signed`; a `signed` entry can therefore never exist without the exact signed bytes, -nonce, hash, intent kind, and fee fields needed for cleanup. Stored-artifact retries become -deliverable only after that transaction commits. The same idempotent compensation path that +conditionally promotes the catalog entry to `signed`. For an Ecrecover quote, the catalog entry stores the exact +tree signature and non-maker publication payload; it has no maker nonce, transaction hash, signed +transaction bytes, or fee fields and creates no transaction-inventory record. For every maker +transaction, including setup remediation, finalization atomically writes the separate +ledger-independent transaction-inventory record before promoting the catalog entry; the inventory +contains the exact signed bytes, nonce, hash, intent kind, and fee fields needed for cleanup. A +transaction-backed `signed` catalog entry can therefore never exist without its inventory record, +while a signature-only `signed` entry is explicitly distinguishable and remains available for root +or group revocation. Stored-artifact retries become deliverable only after the applicable +finalization transaction commits. The same idempotent compensation path that releases a failed primary reservation also writes a terminal `failed` tombstone for its catalog entry. Break-glass enumerates only `signed` catalog entries; `pending-signature` entries past their short lease are reconciled to `signed` only from complete durable artifacts, otherwise they are @@ -301,7 +308,13 @@ accounting, replacement rules, and break-glass preemption guarantees as every ot transaction. Its invoke role is separate from both the bot and break-glass roles, and neither role can invoke it. Direct bot or operator access to `kms:Sign` may not be removed until this surface is deployed and its positive and deny-path acceptance tests pass; after cutover, no manual remediation -procedure may restore direct KMS signing. +procedure may restore direct KMS signing. Before that cutover, migration must also backfill the +independent catalog and transaction inventory with every non-terminal offer group, ratified root, +pending routine transaction, and pending setup-remediation transaction signed by the existing `aws` +path, including complete artifact and occupied-nonce histories. Alternatively, deployment must prove +that every pre-middleware offer, root, and transaction is terminal on chain. Direct KMS access cannot +be removed until the backfill or terminal-state proof passes the same strongly consistent inventory, +pending-set reconciliation, and break-glass preflight used after cutover. **Quote intents** carry an array of structured offers. There are **no caller-declared exclusions**: the prospective book is always the observed live book plus the proposed set, @@ -467,11 +480,13 @@ when any production alias has additional version weights. IAM grants only those the setup role's bounded `GetItem`/`BatchGetItem`, and `lambda:GetAlias` on the six exact production-alias ARNs (or an equivalently authenticated deployment manifest containing their exact published-version targets); it grants no wildcard Lambda reads. The setup role resolves and records -those alias targets before accepting registry attestations. No attestation path invokes a signing -handler or exposes a signing operation. Each published function version also exposes a dedicated -**non-signing attestation entrypoint** in the same image. After publishing a version and before moving its -production alias, deployment automation invokes that entrypoint under the function execution role; -it performs only the key/config/image validation above and the conditional registry write. The +those alias targets before accepting registry attestations. No attestation path exposes a signing +operation. The production handler of each published function version routes a dedicated +**non-signing attestation operation** before any signing dispatch; it is +not a separate Lambda, alternate image command, or handler configuration. After publishing a +version and before moving its production alias, deployment automation invokes that exact version and +handler with the attestation operation under the function execution role; the operation performs +only the key/config/image validation above and the conditional registry write. The deployment verifies the exact version-and-manifest record, retries transient failures, and refuses the alias rollout if the record is absent or mismatched. Readiness can therefore require fresh records without waiting for quote, ratify, or revoke traffic, while setup/health still never invokes @@ -896,7 +911,9 @@ host itself; it strengthens rather than changes this design. - **Misbehavior of the providers behind the Lambda's own reads** — a lying or censoring RPC/API could wave through a crossed or unsustainable set, or block valid ones. This extends the provider-trust posture of [TIB-2026-07-27](./TIB-2026-07-27-midnight-quoter-bot.md) to the - middleware; the disagreement posture is open question 7. + middleware. The v0 deliverable must pin provider members and a quorum threshold, then fail closed + with no KMS call on disagreement, insufficient quorum, or an incoherent snapshot; quote/ratify + stays disabled until that configuration and its integration tests are complete (Open Question 6). - **DoS via invocation throttling or concurrency exhaustion** — quoting downtime; resting offers stand until expiry or revocation through a break-glass invoker. From 7f31ecc49404714c6c574b70a9689d624f2e2197 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:08:57 +0000 Subject: [PATCH 30/37] docs(quoter-bot): close KMS middleware safety gaps Require clean-key cutover, nonce-complete break-glass cancellation, stale-lease reconciliation, enforced balance admission, scheduled attestation refresh, and complete setup-remediation policy vectors. --- ...08-12-quoter-bot-kms-signing-middleware.md | 116 +++++++++++++----- 1 file changed, 86 insertions(+), 30 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 7091099e..7d7ce9ad 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -197,8 +197,15 @@ incident. It does **not** sign the node's `pending` nonce, which is the next unu enumerates every occupied nonce from the middleware's recorded, still-pending routine and setup-remediation transactions and signs a cleanup transaction at each **same nonce**, thereby replacing every unsafe publication, ratification, or remediation rather than queueing behind it. -Each replacement fee bid uses the repository replacement formula against the maximum recorded fee -fields across every signature at that nonce: the priority fee is bumped by 12.5% plus the one-wei +When a nonce has no root/group revocation target — including a maintenance approval or authorization +nonce, or when there are fewer revocation targets than occupied nonces — the protected handler signs +an explicit break-glass self-cancel replacement: sender and target are the maker EOA, value is zero, +calldata is empty, and the dedicated break-glass-cancel intent rejects every offer, root, group, +approval, authorization, and arbitrary target field. Empty revocation batches remain invalid; this +self-cancel is a distinct replacement intent, so every occupied nonce always has a policy-valid +preemption payload. Each replacement fee bid uses the repository replacement formula against the +maximum recorded fee fields across every signature at that nonce: the priority fee is bumped by +12.5% plus the one-wei floor, and `maxFeePerGas` is the maximum of its corresponding bump and `currentBaseFee * 2 + newPriorityFee`. The current pending-block base fee comes from an independent @@ -289,12 +296,18 @@ preemption. Reserved concurrency isolates revoke capacity from quote floods but **not** a rate limit; it cannot bound sequential signatures. If that independent budget cannot be checked atomically, revoke signing fails closed and pages the operator rather than becoming unmetered. The final backstop is a -**native-balance funding ceiling** — a new operational control this TIB requires, distinct from -the existing `NATIVE_RESERVE_WEI` **minimum** readiness threshold: the operator funds the maker -EOA between that minimum and a configured maximum, and monitoring alerts when the balance -exceeds the maximum. Gas grief can never exceed the remaining routine budget plus the protected -emergency allowance, and never exceed what is funded. Native-gas spend is thereby capped and -enters the bounded-loss arithmetic. +**native-balance admission ceiling** — a new operational control this TIB requires, distinct from +the existing `NATIVE_RESERVE_WEI` **minimum** readiness threshold. Setup/readiness and every routine +quote, ratify, revoke, replacement, and setup-remediation signing request independently read the +maker's native balance and fail closed before KMS when it exceeds the configured maximum. Monitoring +alerts before and at that limit; an unsolicited transfer can make the balance exceed the target but +cannot turn the excess into routine signing capacity. The operator-only break-glass path remains +available to replace unsafe pending transactions and move excess native balance during containment, +subject to its protected budget and exact allowlist. Routine gas grief is therefore bounded by the +remaining routine budget and by the enforced admission ceiling, while break-glass spend is separately +bounded by the protected emergency allowance. Native-gas spend enters the bounded-loss arithmetic +under those separate authenticated budget classes; the configured maximum is never described as a +physical balance cap that external senders cannot exceed. **Setup-remediation intents** replace the maker's direct KMS path for token approvals and other setup transactions. They are accepted only by a dedicated operator-authorized function while a @@ -308,13 +321,19 @@ accounting, replacement rules, and break-glass preemption guarantees as every ot transaction. Its invoke role is separate from both the bot and break-glass roles, and neither role can invoke it. Direct bot or operator access to `kms:Sign` may not be removed until this surface is deployed and its positive and deny-path acceptance tests pass; after cutover, no manual remediation -procedure may restore direct KMS signing. Before that cutover, migration must also backfill the -independent catalog and transaction inventory with every non-terminal offer group, ratified root, +procedure may restore direct KMS signing. Cutover must use a newly generated maker key/address that the +old direct-signing path never held. The old maker remains quarantined: operators revoke every live +offer/root authorization, replace or confirm every pending transaction, remove its token approvals, +and wait for every permit, authorization, and other time-bounded signature class to expire before +moving assets or policy caps to the new maker. The independent catalog backfill below is still required +for cleanup, but it is not accepted as proof that arbitrary historical blind signatures are exhausted, +because CloudTrail cannot recover their signed digest. Before cutover, migration backfills the +independent catalog and transaction inventory with every known non-terminal offer group, ratified root, pending routine transaction, and pending setup-remediation transaction signed by the existing `aws` -path, including complete artifact and occupied-nonce histories. Alternatively, deployment must prove -that every pre-middleware offer, root, and transaction is terminal on chain. Direct KMS access cannot -be removed until the backfill or terminal-state proof passes the same strongly consistent inventory, -pending-set reconciliation, and break-glass preflight used after cutover. +path, including complete artifact and occupied-nonce histories. Direct KMS access cannot be removed +and higher caps cannot be enabled until the new maker is active, the old maker's known artifacts pass +the same strongly consistent inventory, pending-set reconciliation, and break-glass preflight used +after cutover, and the old maker has completed the quarantine conditions above. **Quote intents** carry an array of structured offers. There are **no caller-declared exclusions**: the prospective book is always the observed live book plus the proposed set, @@ -488,8 +507,16 @@ version and before moving its production alias, deployment automation invokes th handler with the attestation operation under the function execution role; the operation performs only the key/config/image validation above and the conditional registry write. The deployment verifies the exact version-and-manifest record, retries transient failures, and refuses -the alias rollout if the record is absent or mismatched. Readiness can therefore require fresh -records without waiting for quote, ratify, or revoke traffic, while setup/health still never invokes +the alias rollout if the record is absent or mismatched. After rollout, an external EventBridge +schedule invokes the same non-signing attestation operation on each exact production alias at an +interval shorter than half the registry freshness window. The operation re-resolves the alias to one +published version, rejects weighted routing, revalidates key/config/image/manifest state, and +conditionally refreshes only that version-and-manifest record. A deployment-owned watchdog alarms +before a record can expire and retries transient refresh failures; setup/health never refreshes a +signing surface on its behalf. If scheduled refresh stops or drift prevents a refresh, readiness turns +red at the freshness deadline and signing fails closed, without requiring a redeploy or signing +traffic. Readiness can therefore require fresh records without waiting for quote, ratify, or revoke +traffic, while setup/health still never invokes a signing handler. The ports serve **every maker workflow, not only the ladder**: position bootstrap (including @@ -583,8 +610,20 @@ logic, and any alternative host must preserve the trust split: the middleware al emergency budget, not only in the primary reservation ledger. Every routine handler must read the independent generation, acquire both its primary reservation and an independent generation-scoped lease before KMS signing, recheck that independent lease immediately before the - KMS call, and release it only after the result is durably cataloged. Failure of either control - plane fails routine signing closed. + KMS call, and release it only after the result is durably cataloged. Leases have a short manifest-pinned + TTL, a bounded heartbeat, and explicit `reserved`, `kms-in-flight`, and `cataloged` phases. A handler + may renew only while its Lambda invocation is live; the maximum invocation duration plus a fixed grace + period is shorter than containment's lease-reconciliation deadline. An independent reconciler treats + an expired `cataloged` lease as drainable only after it finds the complete signed artifact in the + independent catalog. It treats an expired `reserved` lease as drainable only after the invocation is + terminal and a conditional request record proves no KMS call began. For an expired `kms-in-flight` + lease, missing or incomplete artifact evidence is never interpreted as no signature: the reconciler + waits for the bounded invocation to terminate and requires either the complete catalog artifact or an + attested terminal invocation record proving no KMS result was returned to the handler and no response + was delivered. Otherwise containment fails closed into ordered drain/handoff and pages the operator. + The handler cannot return a signature before the artifact and `cataloged` phase commit, so a crashed + or hung invocation cannot leave a deliverable signature behind an expired clean lease. Failure of + either control plane fails routine signing closed. - The operator-authorized `break-glass-revoke` function is the control surface: on its first cleanup request it conditionally acquires the single active cleanup epoch and increments the independent deny generation atomically, then refuses to sign cleanup transactions until every older-generation @@ -920,8 +959,9 @@ host itself; it strengthens rather than changes this design. Attacker-obtainable revocations are downtime/griefing plus bounded native-gas spend, not loan-asset loss — per-intent invoke scoping and the per-surface gas budgets keep that griefing hard and capped, the protected revoke reserve keeps a compromised routine invoker from starving -break-glass capacity, and the native-balance funding ceiling — an explicit operational control, -distinct from the `NATIVE_RESERVE_WEI` minimum — is the hard cap even during a ledger outage. +break-glass capacity, and the native-balance admission ceiling — an explicit fail-closed signing +control distinct from the `NATIVE_RESERVE_WEI` minimum — prevents unsolicited excess balance from +becoming routine attack capacity. The protected break-glass allowance is accounted separately. **Bounded-loss framing.** Today, bot-host compromise means unbounded loss of everything the maker EOA holds or has approved. With the middleware, it means a bounded, pre-computable number derived @@ -935,8 +975,9 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no ## Testing and Verification -- **Policy:** exhaustive accept/reject vectors for the three properties and every field check on - all three intent types, including boundary values — price exactly at a bound, expiry exactly at +- **Policy:** exhaustive accept/reject vectors for the three properties and every applicable field check + across all four structured intent types — quote, ratify, revoke, and setup remediation — including + boundary values — price exactly at a bound, expiry exactly at maturity or the freshness ceiling, aggregate exposure that overflows only in combination with live offers or outstanding reservations, sign-and-withhold sequences denied once reservations exhaust the caps — through quote or ratify approvals alike, two individually valid withheld @@ -957,8 +998,12 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no signs, both/neither of `maxUnits`/`maxAssets` set, off-by-one exposure caps, a prospective set that crosses only in combination with live offers, PnL vectors for profitable, exact-boundary, and loss-making fills using only the specified - independent cost-basis inputs, and provider disagreement or insufficient quorum denied with no - KMS call. + independent cost-basis inputs; setup-remediation vectors cover every manifest-pinned target, + selector, chain, asset, spender/operator, allowance/authorization ceiling, zero-value rule, + nonce lease, rolling gas budget, idempotency key, same-nonce replacement, artifact/catalog + persistence, and break-glass preemption path, with off-by-one ceilings, foreign fields, arbitrary + calldata, and missing persistence denied; and provider disagreement or insufficient quorum denied + with no KMS call. - **Adversarial state:** intents accompanied by caller-supplied book or position state that contradicts chain truth — the Lambda must ignore the caller's view entirely and decide from its own reads. @@ -979,10 +1024,13 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no denial propagation into `make.rejected`, the invocation-failure halt, a break-glass revoke invoked by a second IAM principal, an attempted bot invocation of `break-glass-revoke` denied by IAM, a routine-revoke payload that claims break-glass identity still charged only to the routine - budget, and the funding-ceiling alert when the maker's native balance exceeds its configured - maximum. Queue routine publications at consecutive occupied nonces, then prove break-glass - pre-signs and broadcasts replacements for every occupied nonce before waiting rather than signing - only the lowest or the next unused nonce. Raise the base fee above the routine max-fee bump and + budget. Prove the admission-ceiling alert and readiness failure when the maker's native balance + exceeds its configured maximum, and prove every routine/remediation signing surface rejects before + KMS while operator-only break-glass remains available. Queue routine publications at consecutive + occupied nonces plus a setup-remediation approval at a nonce with no revocation target, then prove + break-glass pre-signs and broadcasts replacements for every occupied nonce before waiting, using + the exact break-glass self-cancel for the remediation-only nonce rather than signing only the lowest + or the next unused nonce. Raise the base fee above the routine max-fee bump and prove break-glass uses `baseFee * 2 + newPriorityFee`, then fails closed when that live result exceeds the protected ceiling. Fill the configured occupied-nonce limit and prove a new-nonce request is denied while a same-nonce replacement remains allowed; race requests for the final slot @@ -990,7 +1038,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no inventory's exact nonce and fee records; a missing, incoherent, stale-replica, or lagging-watermark inventory forces ordered handoff. Enter break-glass while routine-revoke is in flight and prove its generation lease drains before cleanup and no new routine revoke can be signed until emergency deny - is cleared. + is cleared. Crash handlers separately in `reserved`, `kms-in-flight`, and `cataloged`, advance past + the lease TTL and invocation timeout, and prove reconciliation drains only a terminal pre-KMS + request or a complete catalog artifact; an ambiguous KMS result keeps containment fail-closed. + Stop the scheduled attestation refresh, prove records age red without signing traffic, then restore + refresh and prove each exact alias/version record becomes fresh without a redeploy. - **Retry and delayed-broadcast safety:** drop the first Lambda response after the durable `signed` transition and prove the transaction inventory already contains the exact artifact before an idempotent retry returns byte-identical stored artifacts without a second KMS call. Inject an @@ -1004,7 +1056,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no prove the independent inventory retains every hash, byte string, and fee pair append-only, marks only the newest entry active for routine replacement, and derives break-glass fees from the maximum across all three artifacts. -- **IAM cutover proof:** demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` +- **IAM cutover proof:** demonstrate the middleware uses a newly generated maker key/address never + exposed to the direct `aws` signer, while the old maker remains quarantined until known artifacts + are revoked/reconciled, approvals are removed, and every permit/authorization signature class has + expired; inventory backfill alone is not accepted as proof against unknown historical blind + signatures. Demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned maker key, that the setup/health role cannot call `kms:Sign`, that each role can From 0aed5a12879fc3543040abf50f0990f6f458039d Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:34:27 +0000 Subject: [PATCH 31/37] docs(quoter-bot): close middleware review gaps Specify the recovery sweep, signing-path attestation gate, exact DynamoDB action budget, complete intent count, and clean-key cutover requirement. --- ...08-12-quoter-bot-kms-signing-middleware.md | 91 ++++++++++++------- 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 7d7ce9ad..b1ab06cb 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -67,7 +67,9 @@ AWS KMS or equivalent key custody. KMS custody has since shipped. This TIB addre Alternative 3). - Generalizing to other bots or keys in v0. The design should not preclude it, but v0 serves one bot and one maker key. -- Changing KMS/HSM procurement. The existing `ECC_SECG_P256K1` KMS key stays where it is. +- Changing KMS/HSM provider procurement. v0 remains on AWS KMS with `ECC_SECG_P256K1`, but the + cutover uses a newly generated maker key/address that the old direct-signing path never held; the + existing maker key material is quarantined and is never reused by the middleware. ## Current Solution @@ -123,9 +125,9 @@ bot host (role: invoke-only) signing Lambda (role: kms:Sign + reads) ### 1. Structured intents replace digests The wire contract is a **versioned JSON intent** carried as the payload of an AWS SDK -`lambda:InvokeFunction` call. The bot submits one of three intent types; the middleware returns -the signatures together with the exact payloads it encoded, so the bot broadcasts exactly what -was validated. +`lambda:InvokeFunction` call. Callers submit one of four intent types — revoke, quote, ratify, or +setup-remediation — and the middleware returns the signatures together with the exact payloads it +encoded, so the caller broadcasts exactly what was validated. **Revoke intents** invalidate offer groups/roots. They are **near-unconditionally approved** — revocation only reduces exposure and is the always-available kill switch — constrained to: pinned @@ -298,28 +300,37 @@ sequential signatures. If that independent budget cannot be checked atomically, fails closed and pages the operator rather than becoming unmetered. The final backstop is a **native-balance admission ceiling** — a new operational control this TIB requires, distinct from the existing `NATIVE_RESERVE_WEI` **minimum** readiness threshold. Setup/readiness and every routine -quote, ratify, revoke, replacement, and setup-remediation signing request independently read the -maker's native balance and fail closed before KMS when it exceeds the configured maximum. Monitoring -alerts before and at that limit; an unsolicited transfer can make the balance exceed the target but -cannot turn the excess into routine signing capacity. The operator-only break-glass path remains -available to replace unsafe pending transactions and move excess native balance during containment, -subject to its protected budget and exact allowlist. Routine gas grief is therefore bounded by the -remaining routine budget and by the enforced admission ceiling, while break-glass spend is separately -bounded by the protected emergency allowance. Native-gas spend enters the bounded-loss arithmetic -under those separate authenticated budget classes; the configured maximum is never described as a -physical balance cap that external senders cannot exceed. +quote, ratify, revoke, replacement, and non-sweep setup-remediation signing request independently +read the maker's native balance and fail closed before KMS when it exceeds the configured maximum. +Monitoring alerts before and at that limit; an unsolicited transfer can make the balance exceed the +target but cannot turn the excess into routine signing capacity. The operator-only break-glass path +remains available to replace unsafe pending transactions, and the setup-remediation surface provides +the sole native-balance recovery operation described below. +Routine gas grief is therefore bounded by the remaining routine budget and by the enforced admission +ceiling, while break-glass spend is separately bounded by the protected emergency allowance. +Native-gas spend enters the bounded-loss arithmetic under those separate authenticated budget +classes; the configured maximum is never described as a physical balance cap that external senders +cannot exceed. **Setup-remediation intents** replace the maker's direct KMS path for token approvals and other setup transactions. They are accepted only by a dedicated operator-authorized function while a maintenance cleanup epoch has stopped quote, ratify, and routine-revoke signing. The deployment manifest pins every permitted target, selector, asset, spender/operator, allowance or authorization -ceiling, chain, zero native value, and gas/fee ceiling; the middleware independently reads current -allowance/authorization state and canonically encodes the exact transaction. Arbitrary calldata, -permit signatures, asset transfers, wildcard spenders/operators, and caller-selected targets are -rejected. Setup remediation uses the same nonce lease, append-only artifact history, rolling gas -accounting, replacement rules, and break-glass preemption guarantees as every other maker -transaction. Its invoke role is separate from both the bot and break-glass roles, and neither role -can invoke it. Direct bot or operator access to `kms:Sign` may not be removed until this surface is +ceiling, chain, native value, and gas/fee ceiling; the middleware independently reads current +allowance/authorization state and canonically encodes the exact transaction. All variants require +zero native value except one manifest-pinned `native-balance-sweep` variant. That variant is allowed +only when the independently read balance exceeds the admission ceiling, sends only to one configured +treasury address with empty calldata, and derives (never accepts) a value that leaves the configured +recovery target plus worst-case transaction fees on the maker. It rejects a caller-supplied target or +value, token calls, arbitrary calldata, and any result below the native reserve; it remains available +above the routine admission ceiling but still requires a fresh signing-surface attestation, the +maintenance epoch, nonce lease, protected remediation gas budget, and exact manifest match. +Arbitrary calldata, permit signatures, token asset transfers, wildcard spenders/operators, and +caller-selected targets are rejected. Setup remediation uses the same nonce lease, append-only +artifact history, rolling gas accounting, replacement rules, and break-glass preemption guarantees +as every other maker transaction. Its invoke role is separate from both the bot and break-glass +roles, and neither role can invoke it. Direct bot or operator access to `kms:Sign` may not be removed +until this surface is deployed and its positive and deny-path acceptance tests pass; after cutover, no manual remediation procedure may restore direct KMS signing. Cutover must use a newly generated maker key/address that the old direct-signing path never held. The old maker remains quarantined: operators revoke every live @@ -486,8 +497,9 @@ The aggregation path is an internal attestation registry, not an assertion synth setup/health. After validating itself, each execution role may conditionally write only its own function-and-published-version key in a dedicated DynamoDB table; the value contains the validated shared fields, that surface's policy/configuration digest, the deployment-manifest digest, and a -startup timestamp. It cannot write another surface's key or read the table. The setup/health -execution role may write its own key and read all six exact keys, but may not alter the five +startup timestamp. It cannot write another surface's key and may strongly consistently read only its +own exact key. The setup/health execution role may write its own key and read all six exact keys, but +may not alter the five signing-surface records; the bot role has no table access. Readiness resolves every production alias to its current published version, requires `RoutingConfig.AdditionalVersionWeights` to be empty, and requires a fresh matching record for that @@ -495,12 +507,18 @@ exact version and manifest. Weighted/canary routing is forbidden for the six pro v0, so an unattested additional version cannot receive signing traffic and an attestation from a retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed when any production alias has additional version weights. IAM grants only those per-key -`dynamodb:PutItem` operations, +`dynamodb:PutItem` operations, each signing role's `GetItem` on only its own exact key, the setup role's bounded `GetItem`/`BatchGetItem`, and `lambda:GetAlias` on the six exact production-alias ARNs (or an equivalently authenticated deployment manifest containing their exact published-version targets); it grants no wildcard Lambda reads. The setup role resolves and records -those alias targets before accepting registry attestations. No attestation path exposes a signing -operation. The production handler of each published function version routes a dedicated +those alias targets before accepting registry attestations. Before dispatching any quote, ratify, +routine-revoke, break-glass-revoke, or setup-remediation request to KMS, that signing handler performs +a strongly consistent read of its own exact function-version-and-manifest record and requires its +maker, chain, key fingerprint, image digest, policy digest, manifest digest, and timestamp to match +and remain inside the freshness window. A missing, stale, or mismatched record rejects the request +before reservation or KMS; callers cannot bypass the gate, and setup/readiness cannot satisfy it on +a signing handler's behalf. No attestation path exposes a signing operation. The production handler +of each published function version routes a dedicated **non-signing attestation operation** before any signing dispatch; it is not a separate Lambda, alternate image command, or handler configuration. After publishing a version and before moving its production alias, deployment automation invokes that exact version and @@ -708,12 +726,21 @@ reserved. Offer exposure follows the normal observation, cancellation, or freshn rules, but EOA transaction records and their signed-gas/nonce reservations never release on offer freshness expiry; they remain until the nonce is confirmed or replaced/cancelled on chain. -v0 uses DynamoDB and sets a **middleware-mode rung cap of 40 per side** (80 offers), lower than the -general quoter limit. The reservation planner has a fixed, tested write-action budget covering the -80 per-offer records plus every aggregate counter, generation condition, gas-budget update, and -idempotency marker, and rejects any plan above DynamoDB's 100-action `TransactWriteItems` limit -before validation or signing. No request is chunked across transactions: raising the cap requires a -store or schema that can atomically commit the larger maximum tree, plus updated boundary tests. +v0 uses DynamoDB and sets a **middleware-mode rung cap of 40 per side** (80 offers) and a +**per-intent cap of seven distinct markets**. The reservation planner computes its exact primary +transaction cost as `N + C + 6`: one action for each offer record (`N ≤ 80`), one action for each +affected market-side lend or unwind counter domain (`C ≤ 2 × markets ≤ 14`), and six reserved fixed +actions for the maker-wide counter, signed-gas budget, deny-generation fence, idempotency marker, +signing-attempt record, and one catalog-manifest item containing the intent's complete root/group +inventory. The fixed actions remain budgeted even when a particular intent does not use every +counter. Consequently the largest valid plan is `80 + 14 + 6 = 100` actions and fits exactly inside +DynamoDB's `TransactWriteItems` limit. The catalog manifest is size-checked before admission. Signed- +artifact chunk writes occur only after KMS and are therefore outside the pre-sign action count; they +must still complete, verify, and durably transition the attempt to `signed` before any response is +returned, as specified above. Boundary tests cover 80 offers across seven markets with both counter +domains and reject an eighth market or any computed plan above 100 actions before validation or +signing. No request is chunked across reservation transactions: raising either cap requires a store +or schema that can atomically commit the larger maximum tree, plus updated boundary tests. Publication is tracked per leaf, not merely per root. A reservation moves from reserved to live when that exact offer is observed and is released on its own freshness expiry. Partial group From b2f1200777bd338127a17ca167d94db408bef86c Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:57:23 +0000 Subject: [PATCH 32/37] docs(quoter-bot): address middleware review follow-ups --- ...08-12-quoter-bot-kms-signing-middleware.md | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index b1ab06cb..a2709b49 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -246,8 +246,9 @@ down. The primary protected reserve is sized for replacements at the **maximum c occupied-nonce set** (each at the protected fee and gas ceilings) plus several full-book cleanups, with no overlap assumed between those costs. Deployment validation computes that bound from the configured maximum and rejects a primary reserve below it. The reservation transaction also counts -the maker's distinct non-terminal routine nonces and rejects any new-nonce signature that would -exceed that configured maximum; same-nonce replacements do not increase the count. This hard cap is +the maker's distinct non-terminal routine and setup-remediation nonces and rejects any new-nonce +signature that would exceed that configured maximum; same-nonce replacements do not increase the +count. This hard cap is checked and updated atomically with nonce lease acquisition, so concurrent invocations cannot each admit themselves against the last slot. Both revoke functions use the same strict revoke validator from the shared image, but their function ARNs, IAM grants, reserved concurrency, and server-side @@ -507,10 +508,11 @@ exact version and manifest. Weighted/canary routing is forbidden for the six pro v0, so an unattested additional version cannot receive signing traffic and an attestation from a retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed when any production alias has additional version weights. IAM grants only those per-key -`dynamodb:PutItem` operations, each signing role's `GetItem` on only its own exact key, -the setup role's bounded `GetItem`/`BatchGetItem`, and `lambda:GetAlias` on the six exact -production-alias ARNs (or an equivalently authenticated deployment manifest containing their exact -published-version targets); it grants no wildcard Lambda reads. The setup role resolves and records +`dynamodb:PutItem` operations, each signing role's `GetItem` on only its own exact key, each signing +role's `lambda:GetAlias` on only its own exact production-alias ARN, the setup role's bounded +`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias` on the six exact production-alias +ARNs (or an equivalently authenticated deployment manifest containing their exact published-version +targets); it grants no wildcard Lambda reads. The setup role resolves and records those alias targets before accepting registry attestations. Before dispatching any quote, ratify, routine-revoke, break-glass-revoke, or setup-remediation request to KMS, that signing handler performs a strongly consistent read of its own exact function-version-and-manifest record and requires its @@ -886,8 +888,10 @@ host itself; it strengthens rather than changes this design. ## Dependencies -- AWS KMS `Sign`/`GetPublicKey` on the existing `ECC_SECG_P256K1` maker key - ([AWS KMS Sign API](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html)). +- AWS KMS `Sign`/`GetPublicKey` on a newly generated `ECC_SECG_P256K1` maker key + ([AWS KMS Sign API](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html)); the old + maker key is referenced only for quarantine and inventory backfill and is never provisioned to the + middleware. - AWS Lambda (container-image function) and ECR for the image, plus IAM for the invoke-only and execution role chain ([Lambda container images](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html)). @@ -1052,8 +1056,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no invoked by a second IAM principal, an attempted bot invocation of `break-glass-revoke` denied by IAM, a routine-revoke payload that claims break-glass identity still charged only to the routine budget. Prove the admission-ceiling alert and readiness failure when the maker's native balance - exceeds its configured maximum, and prove every routine/remediation signing surface rejects before - KMS while operator-only break-glass remains available. Queue routine publications at consecutive + exceeds its configured maximum, and prove every routine and non-sweep setup-remediation signing + surface rejects before KMS while operator-only break-glass remains available. Prove the + manifest-pinned native-balance sweep remains available only above that ceiling and still enforces + its independently derived value, treasury target, maintenance epoch, nonce lease, attestation, and + protected remediation budget. Queue routine publications at consecutive occupied nonces plus a setup-remediation approval at a nonce with no revocation target, then prove break-glass pre-signs and broadcasts replacements for every occupied nonce before waiting, using the exact break-glass self-cancel for the remediation-only nonce rather than signing only the lowest @@ -1098,9 +1105,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no transfers, permits, non-zero value, and attempts outside the epoch produce no KMS call. For every bot and operator surface, prove the exact production alias succeeds while the unqualified function ARN, `$LATEST`, every other version/alias, and every - cross-surface production alias receive `AccessDenied`. Prove setup/health can call - `lambda:GetAlias` on each of the six exact production - aliases, cannot call it on any other function or alias, rejects an attestation whose published + cross-surface production alias receive `AccessDenied`. Prove each signing role can call + `lambda:GetAlias` only on its own exact production alias, while setup/health can call it on each of + the six exact production aliases; prove all of those roles are denied on every other function or + alias. Setup/health rejects an attestation whose published version differs from the resolved alias target, and fails readiness when `RoutingConfig.AdditionalVersionWeights` is non-empty. Prove deployment automation refuses a weighted production-alias rollout. Verify CloudTrail data events cover all six function ARNs. A From 630c7bf73fa9aea0ceb7137c967b7afe20c83a26 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:13:43 +0000 Subject: [PATCH 33/37] docs(quoter-bot): correct GetAlias authorization scope --- ...08-12-quoter-bot-kms-signing-middleware.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index a2709b49..19375a76 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -509,10 +509,13 @@ v0, so an unattested additional version cannot receive signing traffic and an at retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed when any production alias has additional version weights. IAM grants only those per-key `dynamodb:PutItem` operations, each signing role's `GetItem` on only its own exact key, each signing -role's `lambda:GetAlias` on only its own exact production-alias ARN, the setup role's bounded -`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias` on the six exact production-alias -ARNs (or an equivalently authenticated deployment manifest containing their exact published-version -targets); it grants no wildcard Lambda reads. The setup role resolves and records +role's `lambda:GetAlias` on its own function ARN, the setup role's bounded +`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias` on the six exact function ARNs +(or an equivalently authenticated deployment manifest containing their exact published-version +targets); it grants no wildcard Lambda reads. Because `GetAlias` authorizes the function resource +rather than an alias resource, every caller supplies only the exact configured alias name and the +handler/manifest validation rejects any other alias before accepting the returned target. The setup +role resolves and records those alias targets before accepting registry attestations. Before dispatching any quote, ratify, routine-revoke, break-glass-revoke, or setup-remediation request to KMS, that signing handler performs a strongly consistent read of its own exact function-version-and-manifest record and requires its @@ -1106,9 +1109,11 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no bot and operator surface, prove the exact production alias succeeds while the unqualified function ARN, `$LATEST`, every other version/alias, and every cross-surface production alias receive `AccessDenied`. Prove each signing role can call - `lambda:GetAlias` only on its own exact production alias, while setup/health can call it on each of - the six exact production aliases; prove all of those roles are denied on every other function or - alias. Setup/health rejects an attestation whose published + `lambda:GetAlias` only on its own function ARN, while setup/health can call it on each of the six + exact function ARNs; prove all of those roles are denied on every other function. Because IAM + cannot scope `GetAlias` to an alias ARN, prove handler/manifest validation accepts only the exact + configured alias name and rejects every other alias name before trusting the resolved target. + Setup/health rejects an attestation whose published version differs from the resolved alias target, and fails readiness when `RoutingConfig.AdditionalVersionWeights` is non-empty. Prove deployment automation refuses a weighted production-alias rollout. Verify CloudTrail data events cover all six function ARNs. A From 10db5e9add3d741c2294e204ead71f109c05760b Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:38:14 +0000 Subject: [PATCH 34/37] docs(quoter-bot): address KMS middleware review --- ...08-12-quoter-bot-kms-signing-middleware.md | 229 +++++++++++------- 1 file changed, 144 insertions(+), 85 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 19375a76..e648342c 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -299,33 +299,48 @@ preemption. Reserved concurrency isolates revoke capacity from quote floods but **not** a rate limit; it cannot bound sequential signatures. If that independent budget cannot be checked atomically, revoke signing fails closed and pages the operator rather than becoming unmetered. The final backstop is a -**native-balance admission ceiling** — a new operational control this TIB requires, distinct from -the existing `NATIVE_RESERVE_WEI` **minimum** readiness threshold. Setup/readiness and every routine -quote, ratify, revoke, replacement, and non-sweep setup-remediation signing request independently -read the maker's native balance and fail closed before KMS when it exceeds the configured maximum. -Monitoring alerts before and at that limit; an unsolicited transfer can make the balance exceed the -target but cannot turn the excess into routine signing capacity. The operator-only break-glass path +**native-balance admission band** — a new operational control this TIB requires, distinct from the +existing `NATIVE_RESERVE_WEI` readiness threshold. Setup/readiness and every routine transaction, +replacement, and setup-remediation signing request independently read the maker's native balance. +They fail closed before KMS when it exceeds the configured maximum or when the request would violate +the **required protected-balance floor**. For the post-admission occupied-nonce set, that live floor +is `NATIVE_RESERVE_WEI` plus (a) the worst-case gas spend of every non-terminal routine or +setup-remediation artifact that could execute before containment, (b) one derived protected-fee +replacement for every occupied nonce, and (c) the configured protected full-book cleanup cost. The +calculation uses each artifact's gas limit and maximum append-only fee history, the current base fee, +the protected replacement formula, and the same configured occupied-nonce maximum used by readiness. +Admission includes the candidate transaction's worst-case spend before checking the floor, so a +compromised routine invoker cannot consume the native balance that the separately accounted protected +reserve needs. An unprovable balance, fee, artifact, or occupied-nonce input fails closed. +Monitoring alerts before and at both bounds; an unsolicited transfer can make the balance exceed the +ceiling but cannot turn the excess into routine signing capacity. The operator-only break-glass path remains available to replace unsafe pending transactions, and the setup-remediation surface provides -the sole native-balance recovery operation described below. -Routine gas grief is therefore bounded by the remaining routine budget and by the enforced admission -ceiling, while break-glass spend is separately bounded by the protected emergency allowance. -Native-gas spend enters the bounded-loss arithmetic under those separate authenticated budget -classes; the configured maximum is never described as a physical balance cap that external senders -cannot exceed. +the sole native-balance recovery operation described below. A native-balance sweep derives its value +only after reserving its own worst-case gas and this complete protected-balance floor; it never sweeps +protected replacement or cleanup capacity. Native-gas spend enters the bounded-loss arithmetic under +the authenticated budget classes and this live spendable-balance check; neither bound is described as +a physical balance cap that external senders cannot change. **Setup-remediation intents** replace the maker's direct KMS path for token approvals and other setup transactions. They are accepted only by a dedicated operator-authorized function while a -maintenance cleanup epoch has stopped quote, ratify, and routine-revoke signing. The deployment +dedicated remediation epoch has stopped quote, ratify, and routine-revoke signing. This remediation +epoch is distinct from the break-glass cleanup epoch: opening it atomically advances the independent +deny generation, drains all older-generation routine leases, and then permits setup-remediation +signing only; the break-glass control remains available to fence and supersede the epoch before it +signs cleanup. An authenticated close proves every remediation artifact is +durable and every remediation nonce is terminal or safely preempted. The deployment manifest pins every permitted target, selector, asset, spender/operator, allowance or authorization ceiling, chain, native value, and gas/fee ceiling; the middleware independently reads current allowance/authorization state and canonically encodes the exact transaction. All variants require zero native value except one manifest-pinned `native-balance-sweep` variant. That variant is allowed only when the independently read balance exceeds the admission ceiling, sends only to one configured treasury address with empty calldata, and derives (never accepts) a value that leaves the configured -recovery target plus worst-case transaction fees on the maker. It rejects a caller-supplied target or +recovery target, its own worst-case transaction fees, and the required protected-balance floor on the +maker. It rejects a caller-supplied target or value, token calls, arbitrary calldata, and any result below the native reserve; it remains available above the routine admission ceiling but still requires a fresh signing-surface attestation, the -maintenance epoch, nonce lease, protected remediation gas budget, and exact manifest match. +remediation epoch, nonce lease, protected remediation gas budget, live protected-balance check, and +exact manifest match. Arbitrary calldata, permit signatures, token asset transfers, wildcard spenders/operators, and caller-selected targets are rejected. Setup remediation uses the same nonce lease, append-only artifact history, rolling gas accounting, replacement rules, and break-glass preemption guarantees @@ -475,13 +490,14 @@ selected as a new identity method alongside [`signer-identity.utils.ts`](../../bots/quoter-bot/src/config/signer-identity.utils.ts). Any residual generic digest-signing path fails closed. -Middleware mode also adds an authenticated **signer-identity setup port**. Every function execution -role, not the bot role, may call `kms:GetPublicKey` only on the configured maker key. At cold start -and before serving, each of the six deployments derives the secp256k1 address and key fingerprint, +Middleware mode also adds an authenticated **signer-identity setup port**. Every active function +execution role, not the bot role, may call `kms:GetPublicKey` only on the configured maker key. At +cold start and before serving, each deployment in the manifest's mode-aware active surface set derives +the secp256k1 address and key fingerprint, computes its surface-specific policy/configuration digest, and fails closed unless the shared identity fields and that surface's digest equal their separate expected values in the deployment -manifest. Setup/health aggregates those per-surface attestations and is ready only when setup, -quote, ratify, routine-revoke, break-glass-revoke, and setup-remediation report the same maker, +manifest. Setup/health aggregates those per-surface attestations and is ready only when every active +surface reports the same maker, chain, key fingerprint, image digest, and deployment-manifest digest, and each reports its own manifest-pinned surface-specific policy/configuration digest. Surface digests are intentionally not required to equal one another: each signing handler pins a different intent type, invoke principal, @@ -499,26 +515,28 @@ setup/health. After validating itself, each execution role may conditionally wri function-and-published-version key in a dedicated DynamoDB table; the value contains the validated shared fields, that surface's policy/configuration digest, the deployment-manifest digest, and a startup timestamp. It cannot write another surface's key and may strongly consistently read only its -own exact key. The setup/health execution role may write its own key and read all six exact keys, but -may not alter the five -signing-surface records; the bot role has no table +own exact key. The setup/health execution role may write its own key and read every exact key in the +mode-aware active surface set, but may not alter any +signing-surface record; the bot role has no table access. Readiness resolves every production alias to its current published version, requires `RoutingConfig.AdditionalVersionWeights` to be empty, and requires a fresh matching record for that -exact version and manifest. Weighted/canary routing is forbidden for the six production aliases in +exact version and manifest. Weighted/canary routing is forbidden for every active production alias in v0, so an unattested additional version cannot receive signing traffic and an attestation from a retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed when any production alias has additional version weights. IAM grants only those per-key `dynamodb:PutItem` operations, each signing role's `GetItem` on only its own exact key, each signing role's `lambda:GetAlias` on its own function ARN, the setup role's bounded -`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias` on the six exact function ARNs +`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias` on every exact active function ARN (or an equivalently authenticated deployment manifest containing their exact published-version targets); it grants no wildcard Lambda reads. Because `GetAlias` authorizes the function resource rather than an alias resource, every caller supplies only the exact configured alias name and the handler/manifest validation rejects any other alias before accepting the returned target. The setup role resolves and records -those alias targets before accepting registry attestations. Before dispatching any quote, ratify, -routine-revoke, break-glass-revoke, or setup-remediation request to KMS, that signing handler performs -a strongly consistent read of its own exact function-version-and-manifest record and requires its +those alias targets before accepting registry attestations. Every signing request re-resolves its +exact configured production alias immediately before reservation and KMS, requires +`RoutingConfig.AdditionalVersionWeights` to be empty, requires the resolved version to equal the +executing published version, and rejects any mismatch before signing. It then performs a strongly +consistent read of its own exact function-version-and-manifest record and requires its maker, chain, key fingerprint, image digest, policy digest, manifest digest, and timestamp to match and remain inside the freshness window. A missing, stale, or mismatched record rejects the request before reservation or KMS; callers cannot bypass the gate, and setup/readiness cannot satisfy it on @@ -531,7 +549,7 @@ handler with the attestation operation under the function execution role; the op only the key/config/image validation above and the conditional registry write. The deployment verifies the exact version-and-manifest record, retries transient failures, and refuses the alias rollout if the record is absent or mismatched. After rollout, an external EventBridge -schedule invokes the same non-signing attestation operation on each exact production alias at an +schedule invokes the same non-signing attestation operation on each exact active production alias at an interval shorter than half the registry freshness window. The operation re-resolves the alias to one published version, rejects weighted routing, revalidates key/config/image/manifest state, and conditionally refreshes only that version-and-manifest record. A deployment-owned watchdog alarms @@ -539,8 +557,11 @@ before a record can expire and retries transient refresh failures; setup/health signing surface on its behalf. If scheduled refresh stops or drift prevents a refresh, readiness turns red at the freshness deadline and signing fails closed, without requiring a redeploy or signing traffic. Readiness can therefore require fresh records without waiting for quote, ratify, or revoke -traffic, while setup/health still never invokes -a signing handler. +traffic, while setup/health still never invokes a signing handler. Each active alias has a +resource-based permission for the `events.amazonaws.com` service principal conditioned on one exact +EventBridge rule ARN. That rule uses an immutable target payload selecting only the non-signing +attestation operation; no scheduler role receives general `lambda:InvokeFunction` credentials or can +supply a signing intent. CloudTrail records each scheduled invoke. The ports serve **every maker workflow, not only the ladder**: position bootstrap (including auto-refill) signs the same transaction kinds through @@ -565,8 +586,12 @@ an HTTP API, a Cloudflare Worker — replaces the handler and the bot-side adapt logic, and any alternative host must preserve the trust split: the middleware alone holds `kms:Sign`, and callers hold nothing but the right to invoke it. -- The middleware is a set of **six AWS Lambda functions — setup/health, quote, ratify, routine - revoke, break-glass revoke, and setup remediation — built from one shared container image** and +- The middleware is a mode-aware set of AWS Lambda functions built from one shared container image: + setup/health, quote, routine revoke, break-glass revoke, and setup remediation are always active; + Setter additionally activates ratify, while Ecrecover omits ratify function deployment entirely: + there is no ratify production alias, invoke grant, or `kms:Sign` principal. The deployment manifest + is the authoritative active surface set, and readiness, refresh, IAM, and audit coverage enumerate + exactly that set. The functions are invoked through the AWS SDK (`lambda:InvokeFunction`). Routine and break-glass revoke deliberately have separate authenticated invoke surfaces even though they enforce the same structured revoke intent. Setup remediation is @@ -578,21 +603,26 @@ logic, and any alternative host must preserve the trust split: the middleware al `lambda:InvokeFunction` on the exact production-alias ARNs for setup/health and its routine intent surfaces — never an unqualified function ARN, a version ARN, `$LATEST`, or another alias. The bot loses `kms:Sign` and `kms:GetPublicKey` entirely. Operator grants are likewise restricted to the - exact break-glass production-alias ARN. The five signing functions' execution roles are the only - principals with `kms:Sign`; all six execution roles have narrowly scoped `kms:GetPublicKey` on + exact break-glass production-alias ARN. The active signing functions' execution roles are the only + principals with `kms:Sign`; every active execution role has narrowly scoped `kms:GetPublicKey` on that same maker key solely for their startup attestation, while setup/health has no `kms:Sign`. Creating those execution roles, the invoke-only credentials, and - the CloudTrail data-event selectors for all six functions (see Observability) are part of the + the CloudTrail data-event selectors for every active function (see Observability) are part of the deliverable. -- **Caller-to-surface scoping**: principals are authorized per exact production-alias ARN. The four - structured intent types map to **five signing Lambda functions**, while setup/readiness maps to the - sixth setup/health function — one shared container image, six deployments — +- **Caller-to-surface scoping**: principals are authorized per exact production-alias ARN. The + structured intent types map to separate active signing Lambda functions, while setup/readiness maps + to setup/health — one shared container image, five Ecrecover or six Setter deployments — because routine and break-glass revoke must be distinguishable by an authenticated AWS boundary, not by untrusted payload data. Aliases are not enough: reserved concurrency is function-scoped, and distinct functions also let IAM deny the bot access to the protected reserve. IAM grants the - bot setup/health, quote, ratify, and routine-revoke production aliases only; break-glass principals + bot setup/health, quote, and routine-revoke production aliases, plus ratify only in Setter mode; + break-glass principals receive only the break-glass-revoke production alias; remediation operators receive only the - setup-remediation production alias. Explicit denies and acceptance tests cover + setup-remediation production alias. Each exact active production alias grants the + `events.amazonaws.com` service principal permission conditioned on its exact EventBridge rule ARN; + that rule supplies only the immutable non-signing attestation payload. No scheduler execution role + receives a general invoke grant, KMS permission, budget class, or signing-intent path. Explicit + denies and acceptance tests cover unqualified and `$LATEST` invokes, every non-production alias, and cross-surface aliases. Each signing handler pins its intent type and budget class in deployment configuration before calling the shared validator. It never reads a claimed principal or budget class from the intent @@ -605,7 +635,7 @@ logic, and any alternative host must preserve the trust split: the middleware al intents a given principal may submit, and invoke scoping keeps revoke-griefing/DoS hard and the audit trail attributable. - The middleware's **code lives in this monorepo** and deploys as one **Docker container image** - (ECR-hosted) instantiated as the six functions, with its own Dockerfile, like the + (ECR-hosted) instantiated as the mode-aware active functions, with its own Dockerfile, like the bots. It is not a bot — not a long-running program — so it does not live under `/bots/`; the proposed workspace home is a new top-level `services/` directory, e.g. `services/quoter-signer`. Final naming and location @@ -669,6 +699,18 @@ logic, and any alternative host must preserve the trust split: the middleware al generation, evidence snapshot, and request ID to the immutable audit stream. Until that authenticated close succeeds, routine quote, ratify, and routine-revoke remain denied and only operator-authorized break-glass revocation remains available. +- Setup remediation uses separate non-signing `open-remediation-epoch` and + `close-remediation-epoch` operations on the operator-authorized setup-remediation function. Open + conditionally requires no active cleanup or remediation epoch, advances the independent deny + generation, and waits for every older-generation routine lease to drain before remediation may + sign. Each remediation request must hold a generation-scoped lease, recheck the active epoch and + generation immediately before KMS, and durably catalog its artifact before releasing the lease. + Close is allowed only after every remediation transaction is terminal or has a complete + break-glass-preemptable inventory record and no remediation lease remains. It emits an immutable + `middleware.remediation_epoch_closed` event before routine admission advances. If containment is + required during remediation, break-glass atomically fences new remediation leases, drains or + reconciles existing ones, and supersedes the remediation epoch with a cleanup epoch; it never runs + two signing epochs concurrently. - Setter cleanup treats irreversible group cancellations as authoritative. The runbook drains or replaces every already-signed ratification transaction and consumes every affected offer group; `setIsRootRatified(..., false)` is defense in depth, not the sole kill switch, because an older @@ -771,19 +813,19 @@ routine single writer, and break-glass runbook coordinate the stream. ### 7. Failure posture -| Failure | Required behavior | -| ----------------------------------------- | ------------------------------------------------------------------------------- | -| Invocation fails (throttle, error, limit) | Halt quoting (fail closed) and retry; offers stand | -| Cold start latency | Tolerated; the hourly-ish cadence absorbs it | -| Quote intent denied | Typed rejection, nothing signed; alert if persistent | -| Revoke intent denied | Near-impossible by design; treat as misconfig, alert | -| Concurrent tx signers (bot + break-glass) | Replace every recorded occupied nonce before waiting; otherwise ordered handoff | -| Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | -| Reservation ledger unavailable | Quote/ratify/routine revoke closed; break-glass uses independent budget | -| Independent revoke budget unavailable | Revoke fails closed and pages operator | -| KMS error | Typed failure; never assume a signature was produced | -| Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | -| Unknown intent type/version | Reject; no best-effort interpretation of payloads | +| Failure | Required behavior | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Invocation fails (throttle, error, limit) | Halt quoting (fail closed) and retry; offers stand | +| Cold start latency | Tolerated; the hourly-ish cadence absorbs it | +| Quote intent denied | Typed rejection, nothing signed; alert if persistent | +| Revoke intent denied | Near-impossible by design; treat as misconfig, alert | +| Concurrent tx signers (bot + break-glass) | Replace every recorded occupied nonce before waiting; otherwise ordered handoff | +| Read fails or snapshot is incoherent | Fail closed: typed retryable denial, no signature | +| Reservation ledger unavailable | Quote, ratify, routine revoke, and setup remediation closed; break-glass uses independent budget | +| Independent revoke budget unavailable | Revoke fails closed and pages operator | +| KMS error | Typed failure; never assume a signature was produced | +| Policy parameters missing/invalid at init | Refuse to serve; never run a partial or empty policy | +| Unknown intent type/version | Reject; no best-effort interpretation of payloads | ## Considered Alternatives @@ -851,12 +893,14 @@ host itself; it strengthens rather than changes this design. ## Assumptions & Constraints - IAM can express the intended split: the bot's role holds `lambda:InvokeFunction` on exactly - setup/health, quote, ratify, and routine-revoke and nothing else; the five signing Lambda - execution roles are the sole `kms:Sign` principals on the maker key, while all six function - roles hold narrowly scoped `kms:GetPublicKey` solely for per-surface startup attestation; + setup/health, quote, and routine-revoke, plus ratify only in Setter mode. The active signing Lambda + execution roles are the sole `kms:Sign` principals on the maker key, while every active function + role holds narrowly scoped `kms:GetPublicKey` solely for per-surface startup attestation; break-glass operators hold only the separate break-glass-revoke invoke grant, and remediation - operators hold only the setup-remediation invoke grant. Policy selects the budget from that fixed - function deployment, never from caller-controlled request data. + operators hold only the setup-remediation invoke grant. Exact EventBridge rule ARNs receive + resource-based permission to invoke active production aliases with an immutable non-signing + attestation payload; no scheduler role receives general invoke credentials. Policy selects the budget + from that fixed function deployment, never from caller-controlled request data. - Every signed payload class is fully describable as structured intents and canonically encodable inside the Lambda (SDK EIP-712 offer-tree hashing and Mempool payload encoding; viem transaction serialization). @@ -876,12 +920,14 @@ host itself; it strengthens rather than changes this design. transactions, or publishable payloads. They require encryption at rest and in transit, least-privilege read access, no ordinary diagnostic/read-replica access, audited access, and retention/deletion controls aligned with terminal reservation state. Their unavailability fails - quote/ratify and bot-originated routine revokes closed, while + quote/ratify, bot-originated routine revokes, and setup remediation closed, while break-glass revocation must atomically consume the independently stored emergency budget or fail closed and page the operator. -- The maker EOA's native balance is operationally bounded: funded above the `NATIVE_RESERVE_WEI` - minimum and below the new configured funding ceiling, with an alert on breach. The gas-grief - hard cap is this operational control, not a protocol guarantee. +- The maker EOA's native balance is operationally bounded: every routine/remediation admission must + preserve the live required protected-balance floor above `NATIVE_RESERVE_WEI`, and funding remains + below the configured ceiling, with alerts on either breach. The floor includes outstanding routine + spend, protected replacement bids for the occupied set, and protected full-book cleanup. The + gas-grief hard cap is this operational control, not a protocol guarantee. - One invocation round trip per make/revoke job — including cold starts — is compatible with the hourly-ish quote cadence and the one-minute bootstrap monitor. - The Lambda is meaningfully harder to compromise than the bot host — minimal code and @@ -934,9 +980,9 @@ host itself; it strengthens rather than changes this design. - This log is an **authorization audit trail that survives bot-host compromise** — the bot cannot erase or forge it. - CloudTrail covers the full chain: `lambda:InvokeFunction` attributes every caller (bot, - break-glass, or remediation principal), and `kms:Sign` allows exactly the configured **quote, - ratify, routine-revoke, break-glass-revoke, and setup-remediation execution-role ARNs**. The - reconciler maintains that five-role allowlist and maps each role ARN to its one expected intent + break-glass, remediation, or scheduled-attestation principal), and `kms:Sign` allows exactly the + configured active signing execution-role ARNs. The reconciler maintains that mode-aware allowlist + and maps each role ARN to its one expected intent surface; a call from an unknown role or a known role signing for another surface is an incident. The KMS call stream for each role/surface pair must reconcile against the logged per-artifact signing records. CloudTrail `Sign` events do not carry the message or digest, so the join key is the **KMS request ID** each @@ -945,7 +991,7 @@ host itself; it strengthens rather than changes this design. side is an incident signal. Lambda `Invoke` is a CloudTrail **data event and is not logged by default** ([Lambda CloudTrail docs](https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html)); - enabling data-event selectors for all six function ARNs is an explicit v0 deliverable — + enabling data-event selectors for every active function ARN is an explicit v0 deliverable — without complete coverage, the invoke side of this audit trail silently omits intent surfaces. - Alerting on denials, invocation errors/throttles, KMS errors, and independent-read failures. Bot-side `make.rejected` events extend with middleware-denial reasons; invocation-failure halts @@ -1101,23 +1147,36 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned maker key, that the setup/health role cannot call `kms:Sign`, that each role can - invoke only its granted production-alias surfaces, that a break-glass principal is denied on - setup/health, quote, ratify, and setup-remediation, and that the remediation principal is denied on - every non-remediation surface. Prove a permitted setup approval is canonically encoded and signed - only during a maintenance cleanup epoch, while foreign targets/spenders, excessive allowance, - transfers, permits, non-zero value, and attempts outside the epoch produce no KMS call. For every - bot and operator surface, prove the exact production alias - succeeds while the unqualified function ARN, `$LATEST`, every other version/alias, and every - cross-surface production alias receive `AccessDenied`. Prove each signing role can call - `lambda:GetAlias` only on its own function ARN, while setup/health can call it on each of the six - exact function ARNs; prove all of those roles are denied on every other function. Because IAM - cannot scope `GetAlias` to an alias ARN, prove handler/manifest validation accepts only the exact - configured alias name and rejects every other alias name before trusting the resolved target. - Setup/health rejects an attestation whose published - version differs from the resolved alias target, and fails readiness when - `RoutingConfig.AdditionalVersionWeights` is non-empty. Prove deployment automation refuses a - weighted production-alias rollout. Verify CloudTrail data events cover all six function ARNs. A - denied call is part of acceptance, not an incident. + invoke only its granted production-alias surfaces, that Ecrecover omits the ratify function, alias, + invoke grant, and KMS principal while Setter includes and attests it, that a break-glass principal + is denied on setup/health, quote, ratify, and setup-remediation, and that the remediation principal + is denied on every non-remediation surface. Prove a permitted setup approval is canonically encoded + and signed only during a distinct remediation epoch, while foreign targets/spenders, excessive + allowance, transfers, permits, non-zero value, and attempts outside the epoch produce no KMS call. + Prove remediation is denied during a reservation-ledger outage and that opening/closing its epoch + advances the independent deny generation, drains older leases, and never overlaps a cleanup epoch. + For every bot and operator surface, prove the exact production alias succeeds while the unqualified + function ARN, `$LATEST`, every other version/alias, and every cross-surface production alias receive + `AccessDenied`. Prove each signing role can call `lambda:GetAlias` only on its own function ARN, + while setup/health can call it on each exact active function ARN; prove all of those roles are denied + on every other function. Because IAM cannot scope `GetAlias` to an alias ARN, prove handler/manifest + validation accepts only the exact configured alias name and rejects every other alias name before + trusting the resolved target. Setup/health rejects an attestation whose published version differs + from the resolved alias target, and fails readiness when `RoutingConfig.AdditionalVersionWeights` + is non-empty. Add weights after readiness while the registry record is still fresh and prove every + signing handler's per-request alias preflight rejects before reservation or KMS. Prove deployment + automation refuses a weighted production-alias rollout. Prove each exact EventBridge rule ARN can + invoke its active production alias with only the immutable non-signing attestation payload, while a + different rule/service source, inactive alias, cross-surface alias, or arbitrary signing payload is + denied or cannot be configured by the rule target. Verify those invokes and every active function ARN + appear in CloudTrail data events. A denied call is part of + acceptance, not an incident. +- **Protected native-balance proof:** admit routine and setup-remediation artifacts up to the + occupied-nonce boundary, then prove the next candidate and every sweep fail before KMS if their + worst-case spend would leave less than `NATIVE_RESERVE_WEI` plus all outstanding routine spend, + one live-base-fee-derived protected replacement per occupied nonce, and protected full-book cleanup. + Prove the boundary succeeds exactly and that missing balance, fee-history, or occupied-set evidence + fails closed. - Tests follow the repository verification rule: run each new test, break one assertion to confirm it fails, restore it. From a63a6c41c1d4f037e1fb9f2f1e649510625c1d37 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:01:14 +0000 Subject: [PATCH 35/37] docs(quoter-bot): harden attestation controls --- ...08-12-quoter-bot-kms-signing-middleware.md | 95 +++++++++++++------ 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index e648342c..05245d52 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -240,9 +240,15 @@ native balance one valid cancellation at a time. Transaction-signing intents the on **rolling signed-gas budgets** tracked in the reservation ledger (see Statefulness). The budget class comes from the authenticated Lambda invoke surface, never from a caller-supplied principal, intent field, or `ClientContext`: publication, ratification, and the bot-only `routine-revoke` -function draw on the routine budget, while the operator-only `break-glass-revoke` function alone -draws on a **protected reserve** that the bot role has no IAM permission to invoke or draw -down. The primary protected reserve is sized for replacements at the **maximum configured +function draw on the routine budget; the operator-only setup-remediation function draws on a +**dedicated setup-remediation rolling budget**; and the operator-only `break-glass-revoke` function +alone draws on a **protected reserve** that neither the bot nor remediation role has IAM permission +to invoke or draw down. The remediation budget has its own ledger key, rolling window, signed-gas +limit, transaction-count limit, and reserved native-gas accounting. Deployment validation derives +its minimum size from the manifest's maximum remediation transactions per epoch at their configured +gas and fee ceilings (including one replacement for each admitted nonce), rejects a zero or +undersized limit, and forbids charging remediation to the routine or break-glass class. The primary +protected reserve is sized for replacements at the **maximum configured occupied-nonce set** (each at the protected fee and gas ceilings) plus several full-book cleanups, with no overlap assumed between those costs. Deployment validation computes that bound from the configured maximum and rejects a primary reserve below it. The reservation transaction also counts @@ -339,7 +345,7 @@ recovery target, its own worst-case transaction fees, and the required protected maker. It rejects a caller-supplied target or value, token calls, arbitrary calldata, and any result below the native reserve; it remains available above the routine admission ceiling but still requires a fresh signing-surface attestation, the -remediation epoch, nonce lease, protected remediation gas budget, live protected-balance check, and +remediation epoch, nonce lease, dedicated setup-remediation rolling budget, live protected-balance check, and exact manifest match. Arbitrary calldata, permit signatures, token asset transfers, wildcard spenders/operators, and caller-selected targets are rejected. Setup remediation uses the same nonce lease, append-only @@ -496,7 +502,12 @@ cold start and before serving, each deployment in the manifest's mode-aware acti the secp256k1 address and key fingerprint, computes its surface-specific policy/configuration digest, and fails closed unless the shared identity fields and that surface's digest equal their separate expected values in the deployment -manifest. Setup/health aggregates those per-surface attestations and is ready only when every active +manifest. It also calls `lambda:GetFunction` for its own exact function name and executing published +version, reads the AWS-observed `Code.ResolvedImageUri`, requires the digest-qualified URI to equal +the manifest-pinned ECR digest, and records that observed digest. Its role can perform this read only +for its own exact function resource; setup/health receives the same bounded read on the enumerated +active functions. A handler-provided environment value or request field is never accepted as image +evidence. Setup/health aggregates those per-surface attestations and is ready only when every active surface reports the same maker, chain, key fingerprint, image digest, and deployment-manifest digest, and each reports its own manifest-pinned surface-specific policy/configuration digest. Surface digests are intentionally not @@ -525,8 +536,9 @@ v0, so an unattested additional version cannot receive signing traffic and an at retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed when any production alias has additional version weights. IAM grants only those per-key `dynamodb:PutItem` operations, each signing role's `GetItem` on only its own exact key, each signing -role's `lambda:GetAlias` on its own function ARN, the setup role's bounded -`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias` on every exact active function ARN +role's `lambda:GetAlias` and `lambda:GetFunction` on its own function ARN, the setup role's bounded +`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias`/`lambda:GetFunction` on every exact +active function ARN (or an equivalently authenticated deployment manifest containing their exact published-version targets); it grants no wildcard Lambda reads. Because `GetAlias` authorizes the function resource rather than an alias resource, every caller supplies only the exact configured alias name and the @@ -540,16 +552,20 @@ consistent read of its own exact function-version-and-manifest record and requir maker, chain, key fingerprint, image digest, policy digest, manifest digest, and timestamp to match and remain inside the freshness window. A missing, stale, or mismatched record rejects the request before reservation or KMS; callers cannot bypass the gate, and setup/readiness cannot satisfy it on -a signing handler's behalf. No attestation path exposes a signing operation. The production handler -of each published function version routes a dedicated -**non-signing attestation operation** before any signing dispatch; it is -not a separate Lambda, alternate image command, or handler configuration. After publishing a -version and before moving its production alias, deployment automation invokes that exact version and -handler with the attestation operation under the function execution role; the operation performs -only the key/config/image validation above and the conditional registry write. The +No attestation path exposes a signing operation. The shared production handler routes a dedicated +**non-signing attestation operation** before any signing dispatch, but accepts that operation only +when `context.invokedFunctionArn` is the manifest-pinned exact published version during deployment or +the function's dedicated attestation alias after rollout. It rejects the operation on every signing +production alias, so a bot that may invoke quote or routine-revoke cannot refresh an attestation. +Conversely, the dedicated attestation alias dispatches only this operation and rejects every signing +intent before reservation or KMS; it is not a separate Lambda, image command, or handler +configuration. After publishing a version and before moving its production and attestation aliases, +deployment automation invokes that exact version and handler with the attestation operation using a +deployment role that alone may invoke version ARNs; the operation performs only the +key/config/AWS-observed-image validation above and the conditional registry write. The deployment verifies the exact version-and-manifest record, retries transient failures, and refuses the alias rollout if the record is absent or mismatched. After rollout, an external EventBridge -schedule invokes the same non-signing attestation operation on each exact active production alias at an +schedule invokes the same non-signing attestation operation on each exact dedicated attestation alias at an interval shorter than half the registry freshness window. The operation re-resolves the alias to one published version, rejects weighted routing, revalidates key/config/image/manifest state, and conditionally refreshes only that version-and-manifest record. A deployment-owned watchdog alarms @@ -557,11 +573,17 @@ before a record can expire and retries transient refresh failures; setup/health signing surface on its behalf. If scheduled refresh stops or drift prevents a refresh, readiness turns red at the freshness deadline and signing fails closed, without requiring a redeploy or signing traffic. Readiness can therefore require fresh records without waiting for quote, ratify, or revoke -traffic, while setup/health still never invokes a signing handler. Each active alias has a +traffic, while setup/health still never invokes a signing handler. Each attestation alias has a resource-based permission for the `events.amazonaws.com` service principal conditioned on one exact -EventBridge rule ARN. That rule uses an immutable target payload selecting only the non-signing -attestation operation; no scheduler role receives general `lambda:InvokeFunction` credentials or can -supply a signing intent. CloudTrail records each scheduled invoke. +EventBridge rule ARN; signing production aliases have no EventBridge invoke permission. The target is +pinned by rule ARN, target ID, attestation-alias ARN, and canonical input hash in the deployment +manifest. An organization policy/permission boundary explicitly denies `events:PutTargets`, +`events:RemoveTargets`, and `events:DeleteRule` for that rule to every principal except the deployment +role; that role verifies the live target against the manifest after every change. AWS Config plus +CloudTrail alarms on any target mutation, and readiness fails closed when its live +`events:ListTargetsByRule` preflight differs from the pinned target tuple. Even if target management is +compromised before detection, the attestation alias cannot dispatch a signing intent. No scheduler +role receives general `lambda:InvokeFunction` credentials. CloudTrail records each scheduled invoke. The ports serve **every maker workflow, not only the ladder**: position bootstrap (including auto-refill) signs the same transaction kinds through @@ -618,9 +640,12 @@ logic, and any alternative host must preserve the trust split: the middleware al bot setup/health, quote, and routine-revoke production aliases, plus ratify only in Setter mode; break-glass principals receive only the break-glass-revoke production alias; remediation operators receive only the - setup-remediation production alias. Each exact active production alias grants the + setup-remediation production alias. Each active function's dedicated attestation alias grants the `events.amazonaws.com` service principal permission conditioned on its exact EventBridge rule ARN; - that rule supplies only the immutable non-signing attestation payload. No scheduler execution role + production signing aliases grant none. The handler accepts attestation only on that alias (or an + exact version invoked by the deployment role), and the rule supplies only the pinned non-signing + payload. Target-management APIs are denied except to the deployment role and live-target drift fails + readiness. No scheduler execution role receives a general invoke grant, KMS permission, budget class, or signing-intent path. Explicit denies and acceptance tests cover unqualified and `$LATEST` invokes, every non-production alias, and cross-surface aliases. Each @@ -898,8 +923,9 @@ host itself; it strengthens rather than changes this design. role holds narrowly scoped `kms:GetPublicKey` solely for per-surface startup attestation; break-glass operators hold only the separate break-glass-revoke invoke grant, and remediation operators hold only the setup-remediation invoke grant. Exact EventBridge rule ARNs receive - resource-based permission to invoke active production aliases with an immutable non-signing - attestation payload; no scheduler role receives general invoke credentials. Policy selects the budget + resource-based permission to invoke dedicated attestation aliases with an immutable non-signing + attestation payload; signing production aliases receive no such permission, and only the deployment + role may mutate the pinned targets. No scheduler role receives general invoke credentials. Policy selects the budget from that fixed function deployment, never from caller-controlled request data. - Every signed payload class is fully describable as structured intents and canonically encodable inside the Lambda (SDK EIP-712 offer-tree hashing and Mempool payload encoding; viem @@ -1109,7 +1135,10 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no surface rejects before KMS while operator-only break-glass remains available. Prove the manifest-pinned native-balance sweep remains available only above that ceiling and still enforces its independently derived value, treasury target, maintenance epoch, nonce lease, attestation, and - protected remediation budget. Queue routine publications at consecutive + dedicated setup-remediation rolling budget. Exhaust that budget by signed gas and independently by + transaction count; prove further remediation fails before KMS, cannot spill into the routine or + break-glass class, and becomes admissible only after the configured rolling window releases capacity. + Queue routine publications at consecutive occupied nonces plus a setup-remediation approval at a nonce with no revocation target, then prove break-glass pre-signs and broadcasts replacements for every occupied nonce before waiting, using the exact break-glass self-cancel for the remediation-only nonce rather than signing only the lowest @@ -1157,18 +1186,24 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no advances the independent deny generation, drains older leases, and never overlaps a cleanup epoch. For every bot and operator surface, prove the exact production alias succeeds while the unqualified function ARN, `$LATEST`, every other version/alias, and every cross-surface production alias receive - `AccessDenied`. Prove each signing role can call `lambda:GetAlias` only on its own function ARN, - while setup/health can call it on each exact active function ARN; prove all of those roles are denied + `AccessDenied`. Prove each signing role can call `lambda:GetAlias` and `lambda:GetFunction` only on + its own function ARN, while setup/health can call them on each exact active function ARN; prove all of those roles are denied on every other function. Because IAM cannot scope `GetAlias` to an alias ARN, prove handler/manifest validation accepts only the exact configured alias name and rejects every other alias name before trusting the resolved target. Setup/health rejects an attestation whose published version differs from the resolved alias target, and fails readiness when `RoutingConfig.AdditionalVersionWeights` is non-empty. Add weights after readiness while the registry record is still fresh and prove every signing handler's per-request alias preflight rejects before reservation or KMS. Prove deployment - automation refuses a weighted production-alias rollout. Prove each exact EventBridge rule ARN can - invoke its active production alias with only the immutable non-signing attestation payload, while a - different rule/service source, inactive alias, cross-surface alias, or arbitrary signing payload is - denied or cannot be configured by the rule target. Verify those invokes and every active function ARN + automation refuses a weighted production-alias rollout. Replace a function version with a different + image while preserving all manifest-provided fields and prove the AWS-observed + `Code.ResolvedImageUri` mismatch fails attestation before its registry write. Prove each exact + EventBridge rule ARN can invoke only its dedicated attestation alias with the immutable non-signing + payload; prove the bot cannot refresh through a production alias and that the attestation alias rejects + every signing intent before reservation or KMS. Prove `events:PutTargets`, `events:RemoveTargets`, and + `events:DeleteRule` are denied to bot, scheduler, signing, and operator roles; mutate a target with the + deployment role and prove target-tuple drift fails readiness and alarms before freshness acceptance. + A different rule/service source, inactive alias, cross-surface alias, or arbitrary signing payload is + denied. Verify those invokes and every active function ARN appear in CloudTrail data events. A denied call is part of acceptance, not an incident. - **Protected native-balance proof:** admit routine and setup-remediation artifacts up to the From feb2abb5b71d9e073765787e5613e13b88d532fa Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:20:23 +0000 Subject: [PATCH 36/37] docs(quoter-bot): address attestation review feedback Clarify mode-specific signing surfaces, qualified Lambda reads, EventBridge readiness permissions, and version-ARN signing denial coverage. --- ...08-12-quoter-bot-kms-signing-middleware.md | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index 05245d52..af00f21b 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -502,11 +502,12 @@ cold start and before serving, each deployment in the manifest's mode-aware acti the secp256k1 address and key fingerprint, computes its surface-specific policy/configuration digest, and fails closed unless the shared identity fields and that surface's digest equal their separate expected values in the deployment -manifest. It also calls `lambda:GetFunction` for its own exact function name and executing published -version, reads the AWS-observed `Code.ResolvedImageUri`, requires the digest-qualified URI to equal +manifest. It also calls `lambda:GetFunction` for its own manifest-pinned exact qualified published-version ARN, +reads the AWS-observed `Code.ResolvedImageUri`, requires the digest-qualified URI to equal the manifest-pinned ECR digest, and records that observed digest. Its role can perform this read only -for its own exact function resource; setup/health receives the same bounded read on the enumerated -active functions. A handler-provided environment value or request field is never accepted as image +for that qualified version ARN; setup/health receives the same bounded read on every manifest-pinned +qualified version in the mode-aware active surface set. An unqualified function ARN does not satisfy +this grant. A handler-provided environment value or request field is never accepted as image evidence. Setup/health aggregates those per-surface attestations and is ready only when every active surface reports the same maker, chain, key fingerprint, image digest, and deployment-manifest digest, and each reports its own @@ -536,11 +537,12 @@ v0, so an unattested additional version cannot receive signing traffic and an at retired deployment cannot satisfy the check. Deployment automation and readiness both fail closed when any production alias has additional version weights. IAM grants only those per-key `dynamodb:PutItem` operations, each signing role's `GetItem` on only its own exact key, each signing -role's `lambda:GetAlias` and `lambda:GetFunction` on its own function ARN, the setup role's bounded -`GetItem`/`BatchGetItem`, and the setup role's `lambda:GetAlias`/`lambda:GetFunction` on every exact -active function ARN -(or an equivalently authenticated deployment manifest containing their exact published-version -targets); it grants no wildcard Lambda reads. Because `GetAlias` authorizes the function resource +role's `lambda:GetAlias` on its own unqualified function ARN and `lambda:GetFunction` on its own +manifest-pinned exact qualified published-version ARN, the setup role's bounded +`GetItem`/`BatchGetItem`, the setup role's `lambda:GetAlias` on every exact active unqualified function +ARN, `lambda:GetFunction` on every corresponding manifest-pinned exact qualified published-version +ARN, and `events:ListTargetsByRule` only on every manifest-pinned exact rule ARN. It grants no wildcard +Lambda or EventBridge reads. Because `GetAlias` authorizes the function resource rather than an alias resource, every caller supplies only the exact configured alias name and the handler/manifest validation rejects any other alias before accepting the returned target. The setup role resolves and records @@ -551,8 +553,11 @@ executing published version, and rejects any mismatch before signing. It then pe consistent read of its own exact function-version-and-manifest record and requires its maker, chain, key fingerprint, image digest, policy digest, manifest digest, and timestamp to match and remain inside the freshness window. A missing, stale, or mismatched record rejects the request -before reservation or KMS; callers cannot bypass the gate, and setup/readiness cannot satisfy it on -No attestation path exposes a signing operation. The shared production handler routes a dedicated +before reservation or KMS; callers cannot bypass the gate, and setup/readiness cannot satisfy it on a +signing surface's behalf. No attestation path exposes a signing operation. Every signing intent first requires +`context.invokedFunctionArn` to equal its exact configured production-alias ARN and rejects a version +ARN, unqualified ARN, `$LATEST`, attestation alias, or any other alias before reservation or KMS. The +shared production handler routes a dedicated **non-signing attestation operation** before any signing dispatch, but accepts that operation only when `context.invokedFunctionArn` is the manifest-pinned exact published version during deployment or the function's dedicated attestation alias after rollout. It rejects the operation on every signing @@ -609,9 +614,12 @@ logic, and any alternative host must preserve the trust split: the middleware al `kms:Sign`, and callers hold nothing but the right to invoke it. - The middleware is a mode-aware set of AWS Lambda functions built from one shared container image: - setup/health, quote, routine revoke, break-glass revoke, and setup remediation are always active; - Setter additionally activates ratify, while Ecrecover omits ratify function deployment entirely: - there is no ratify production alias, invoke grant, or `kms:Sign` principal. The deployment manifest + setup/health, routine revoke, break-glass revoke, and setup remediation are always active. + Ecrecover additionally activates quote and omits ratify; Setter additionally activates ratify and + omits quote. Setter omits the quote function, production and attestation aliases, invoke grants, + readiness entry, EventBridge target, and `kms:Sign` principal because its ratify intent already + returns the validated publication payload. Ecrecover likewise has no ratify function, aliases, + invoke grant, readiness entry, EventBridge target, or `kms:Sign` principal. The deployment manifest is the authoritative active surface set, and readiness, refresh, IAM, and audit coverage enumerate exactly that set. The functions are invoked through the AWS SDK @@ -633,11 +641,12 @@ logic, and any alternative host must preserve the trust split: the middleware al deliverable. - **Caller-to-surface scoping**: principals are authorized per exact production-alias ARN. The structured intent types map to separate active signing Lambda functions, while setup/readiness maps - to setup/health — one shared container image, five Ecrecover or six Setter deployments — + to setup/health — one shared container image and five deployments in either mode — because routine and break-glass revoke must be distinguishable by an authenticated AWS boundary, not by untrusted payload data. Aliases are not enough: reserved concurrency is function-scoped, and distinct functions also let IAM deny the bot access to the protected reserve. IAM grants the - bot setup/health, quote, and routine-revoke production aliases, plus ratify only in Setter mode; + bot setup/health and routine-revoke production aliases, plus quote only in Ecrecover mode or ratify + only in Setter mode; break-glass principals receive only the break-glass-revoke production alias; remediation operators receive only the setup-remediation production alias. Each active function's dedicated attestation alias grants the @@ -1175,28 +1184,36 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no signatures. Demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned - maker key, that the setup/health role cannot call `kms:Sign`, that each role can - invoke only its granted production-alias surfaces, that Ecrecover omits the ratify function, alias, - invoke grant, and KMS principal while Setter includes and attests it, that a break-glass principal - is denied on setup/health, quote, ratify, and setup-remediation, and that the remediation principal - is denied on every non-remediation surface. Prove a permitted setup approval is canonically encoded + maker key, that the setup/health role cannot call `kms:Sign`, and that each role can invoke only its + granted production-alias surfaces. Ecrecover includes and attests quote while omitting the ratify + function, aliases, invoke grants, EventBridge target, readiness entry, and KMS principal; Setter + makes the symmetric choice by including and attesting ratify while omitting all quote resources and + permissions. Prove a break-glass principal is denied on setup/health, quote, ratify, and + setup-remediation, and that the remediation principal is denied on every non-remediation surface. + Prove a permitted setup approval is canonically encoded and signed only during a distinct remediation epoch, while foreign targets/spenders, excessive allowance, transfers, permits, non-zero value, and attempts outside the epoch produce no KMS call. Prove remediation is denied during a reservation-ledger outage and that opening/closing its epoch advances the independent deny generation, drains older leases, and never overlaps a cleanup epoch. For every bot and operator surface, prove the exact production alias succeeds while the unqualified function ARN, `$LATEST`, every other version/alias, and every cross-surface production alias receive - `AccessDenied`. Prove each signing role can call `lambda:GetAlias` and `lambda:GetFunction` only on - its own function ARN, while setup/health can call them on each exact active function ARN; prove all of those roles are denied - on every other function. Because IAM cannot scope `GetAlias` to an alias ARN, prove handler/manifest - validation accepts only the exact configured alias name and rejects every other alias name before - trusting the resolved target. Setup/health rejects an attestation whose published version differs - from the resolved alias target, and fails readiness when `RoutingConfig.AdditionalVersionWeights` - is non-empty. Add weights after readiness while the registry record is still fresh and prove every - signing handler's per-request alias preflight rejects before reservation or KMS. Prove deployment - automation refuses a weighted production-alias rollout. Replace a function version with a different - image while preserving all manifest-provided fields and prove the AWS-observed - `Code.ResolvedImageUri` mismatch fails attestation before its registry write. Prove each exact + `AccessDenied`. Prove each signing role can call `lambda:GetAlias` only on its own unqualified + function ARN and `lambda:GetFunction` only on its manifest-pinned exact qualified published-version + ARN. Prove setup/health can make those same reads for every active surface and that all roles are + denied on every other unqualified or qualified function resource. Because IAM cannot scope + `GetAlias` to an alias ARN, prove handler/manifest validation accepts only the exact configured alias + name and rejects every other alias name before trusting the resolved target. Setup/health rejects an + attestation whose published version differs from the resolved alias target, and fails readiness when + `RoutingConfig.AdditionalVersionWeights` is non-empty. Add weights after readiness while the registry + record is still fresh and prove every signing handler's per-request alias preflight rejects before + reservation or KMS. Prove a deployment-role invoke of an exact version succeeds for the non-signing + attestation operation but that quote, ratify, revoke, and remediation signing payloads on that same + version ARN are rejected by `invokedFunctionArn` validation before reservation or KMS. Prove + deployment automation refuses a weighted production-alias rollout. Replace a function version with + a different image while preserving all manifest-provided fields and prove the AWS-observed + `Code.ResolvedImageUri` mismatch fails attestation before its registry write. Prove setup/health can + call `events:ListTargetsByRule` on each manifest-pinned exact rule ARN, but not on an unlisted rule, + and prove bot, scheduler, signing, operator, and remediation roles cannot call it. Prove each exact EventBridge rule ARN can invoke only its dedicated attestation alias with the immutable non-signing payload; prove the bot cannot refresh through a production alias and that the attestation alias rejects every signing intent before reservation or KMS. Prove `events:PutTargets`, `events:RemoveTargets`, and From 0e5386f0cd246b7eced30dfc51cce5f2f0f1df62 Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:39:37 +0000 Subject: [PATCH 37/37] docs(quoter-bot): close direct-signing cutover gap Disable direct signing before the old-maker quarantine expiry window and require cleanup-only access where needed. --- ...08-12-quoter-bot-kms-signing-middleware.md | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md index af00f21b..7e9442c4 100644 --- a/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md +++ b/docs/decisions/TIB-2026-08-12-quoter-bot-kms-signing-middleware.md @@ -351,11 +351,14 @@ Arbitrary calldata, permit signatures, token asset transfers, wildcard spenders/ caller-selected targets are rejected. Setup remediation uses the same nonce lease, append-only artifact history, rolling gas accounting, replacement rules, and break-glass preemption guarantees as every other maker transaction. Its invoke role is separate from both the bot and break-glass -roles, and neither role can invoke it. Direct bot or operator access to `kms:Sign` may not be removed -until this surface is -deployed and its positive and deny-path acceptance tests pass; after cutover, no manual remediation -procedure may restore direct KMS signing. Cutover must use a newly generated maker key/address that the -old direct-signing path never held. The old maker remains quarantined: operators revoke every live +roles, and neither role can invoke it. Direct bot or operator access to `kms:Sign` remains only until +this surface is deployed and its positive and deny-path acceptance tests pass. Cutover then removes +direct KMS signing and verifies `AccessDenied` before the old-maker quarantine expiry window begins; +no manual remediation procedure may restore it. Any retained access needed to finish old-maker cleanup +must use a policy-checked cleanup-only path that cannot sign quotes, ratifications, setup transactions, +or caller-selected payloads, and that path is removed after cleanup. Cutover must use a newly generated +maker key/address that the old direct-signing path never held. The old maker remains quarantined: +operators revoke every live offer/root authorization, replace or confirm every pending transaction, remove its token approvals, and wait for every permit, authorization, and other time-bounded signature class to expire before moving assets or policy caps to the new maker. The independent catalog backfill below is still required @@ -363,10 +366,11 @@ for cleanup, but it is not accepted as proof that arbitrary historical blind sig because CloudTrail cannot recover their signed digest. Before cutover, migration backfills the independent catalog and transaction inventory with every known non-terminal offer group, ratified root, pending routine transaction, and pending setup-remediation transaction signed by the existing `aws` -path, including complete artifact and occupied-nonce histories. Direct KMS access cannot be removed -and higher caps cannot be enabled until the new maker is active, the old maker's known artifacts pass -the same strongly consistent inventory, pending-set reconciliation, and break-glass preflight used -after cutover, and the old maker has completed the quarantine conditions above. +path, including complete artifact and occupied-nonce histories. After the old maker's known artifacts +pass the same strongly consistent inventory, pending-set reconciliation, and break-glass preflight used +after cutover and the new maker is active, direct KMS signing is removed and `AccessDenied` is verified +before the old-maker quarantine expiry window begins. Higher caps remain disabled until the old maker +has completed every quarantine condition above. **Quote intents** carry an array of structured offers. There are **no caller-declared exclusions**: the prospective book is always the observed live book plus the proposed set, @@ -1178,10 +1182,13 @@ bot. The bot host holds only invoke-scoped AWS credentials — no `kms:Sign`, no only the newest entry active for routine replacement, and derives break-glass fees from the maximum across all three artifacts. - **IAM cutover proof:** demonstrate the middleware uses a newly generated maker key/address never - exposed to the direct `aws` signer, while the old maker remains quarantined until known artifacts - are revoked/reconciled, approvals are removed, and every permit/authorization signature class has + exposed to the direct `aws` signer, and prove direct bot and operator access to `kms:Sign` is denied + before the quarantine expiry clock starts. Keep the old maker quarantined until known artifacts are + revoked/reconciled, approvals are removed, and every permit/authorization signature class has expired; inventory backfill alone is not accepted as proof against unknown historical blind - signatures. Demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` + signatures. If cleanup retains old-key access, prove the policy-checked cleanup-only path rejects + quotes, ratifications, setup transactions, and caller-selected payloads, then prove the path is + removed after cleanup. Demonstrate the bot's principal receives `AccessDenied` on `kms:Sign` and `kms:GetPublicKey` after the grant moves, that readiness can invoke setup/health and obtain only its constrained response, that each function role can call `kms:GetPublicKey` only on the pinned maker key, that the setup/health role cannot call `kms:Sign`, and that each role can invoke only its