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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,49 @@ CHARTER only enforces anything if the agent has to go through it. It is a real b

What that setup protects against: an agent exceeding its limits, an agent approving its own escalation, replaying or double-executing an approval, acting on a stale approval, and tampering with the record afterward (detectable). What it does not protect against: someone with write access to the audit file who also recomputes every hash, a compromised CHARTER host, or an approver who approves something bad. The CLI runs with local access to the log, so the OS username is the identity it records. The API identity is whatever the holder of the approver key claims in `X-Charter-Approver`. There is still one shared approver key, so approvers are told apart by the name they claim, not by separate credentials. The full list of what is and is not defended against is in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md).

## Rules

Every proposal is checked against all of these, and every result is returned in the verdict. A `violated` rule is a veto. A `warning` is an escalation.

| Rule | What it checks | Set in the mandate by |
| --- | --- | --- |
| `killSwitch` | Trading is not halted | `charter halt` |
| `symbolAllowlist` | Symbol and side are permitted | `allowedSymbols`, `blockedSymbols`, `allowedSides` |
| `perTradeMaxUsd` and `dailySpendCapUsd` | Size of this trade, and today's total, counting resting orders | `perTradeMaxUsd`, `dailySpendCapUsd` |
| `perSymbolDailyCapUsd` | Today's total in this one symbol, across all agents | `perSymbolDailyCapUsd` |
| `maxTradesPerHour` | How many trades this agent has executed in the last hour | `maxTradesPerHour` |
| `cooldownSeconds` | Time since this agent last traded this symbol | `cooldownSeconds` |
| `maxLimitDeviationPct` | A limit price is not a likely typo (default 5%) | `maxLimitDeviationPct` |
| `maxOpenOrders` | How many CHARTER limit orders are resting | `maxOpenOrders` |
| `sellWithinHoldings` | A sell is covered by what the account holds | always on for sells |
| `maxSlippageBps` | Projected slippage from the live order book | `maxSlippageBps` |
| `maxLeverage` | No more leverage than allowed | `maxLeverage` |
| `dailyDrawdownHaltPct` | The account is not down more than the limit today | `dailyDrawdownHaltPct` |
| `confirmAboveUsd` | Bigger than this needs a human, so it escalates | `confirmAboveUsd` |

The rules that depend on history (spend, trade rate, cooldown, per-symbol total) read the audit log rather than keep counters. A rule that needs information it could not get, such as holdings for a sell, refuses the trade instead of assuming it is fine.

## Order types

```bash
npx tsx src/index.ts propose BTCUSDT BUY --usd 50
npx tsx src/index.ts propose BTCUSDT BUY --type LIMIT --qty 0.001 --limit-price 78000
```

A MARKET order is sized in USD. A LIMIT order is sized in units and a price, and is simulated against the book: whatever can fill at that price or better is projected as a fill, and the rest is reported as resting. A resting order is logged as placed rather than filled, and counts against the spend caps because it can still fill. Quantities and prices are rounded down to the exchange's step and tick sizes, so an order is never larger than what was approved, and one below the minimum is refused before it is sent.

```bash
npx tsx src/index.ts orders
npx tsx src/index.ts cancel BTCUSDT <orderId>
npx tsx src/index.ts halt --cancel-open
```

CHARTER only ever lists or cancels orders it placed itself, identified by their client order id.

## Portfolio value

The drawdown halt and the NAV impact in a verdict use the value of the whole account. Every holding is priced through its USDT pair, or through a BTC, ETH, or BNB pair if it has none, and fiat is priced through its inverse pair. Anything with no route to a dollar price is left out of the total and reported, not guessed. On the testnet account this replaced a hand-picked list of eight assets that captured about a third of the account's value. A drawdown baseline recorded under an older method is ignored, so two methods are never compared against each other.

## Verdict reasons

A verdict is never a bare PASS, VETO, or ESCALATE label. Every `Verdict` carries a `reasons` array with one entry per policy rule that ran, each shaped as `{ rule, outcome, detail }` (`outcome` is `"ok"`, `"warning"`, or `"violated"`). This is the structured attribution behind the decision: which specific rule drove it, and why, not just the final outcome. It's produced by `evaluateProposal` in `src/policy/engine.ts`, which runs every rule and always returns the full result set regardless of decision; the shape itself is `RuleResult` in `src/policy/types.ts`.
Expand Down
10 changes: 8 additions & 2 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ CHARTER enforces limits only on trades that go through it. For that to mean anyt
| Approval replayed, or two approvals race | An approval is granted once; a per-id guard covers simultaneous requests | `src/approval/service.ts` |
| Approval acted on after conditions changed | The proposal is evaluated again, with fresh market data, spend, drawdown, and kill switch, at the moment of approval | `src/approval/service.ts` |
| Approval acted on after it went stale | Approvals expire | `src/approval/state.ts` |
| Trading must stop now | Kill switch vetoes everything, including an already-approved trade not yet executed | `src/policy/rules/killSwitch.ts` |
| Trading must stop now | Kill switch vetoes everything, including an already-approved trade not yet executed. `--cancel-open` also pulls resting orders off the book | `src/policy/rules/killSwitch.ts`, `src/execution/orders.ts` |
| A limit price typo, or too many resting orders | Limit price must be near the market; the number of resting orders is capped | `src/policy/rules/orders.ts` |
| A runaway agent trading too often | Per-agent hourly trade limit, per-symbol cooldown, and a per-symbol daily cap | `src/policy/rules/activity.ts` |
| Selling something not held | A sell must be covered by holdings, and is refused if holdings cannot be read | `src/policy/rules/orders.ts` |
| The record is edited after the fact | Hash-chained log; edits, reordering, and deletions are detected by `audit verify` | `src/audit/log.ts` |
| Acting on a tampered record | The chain is verified before an approval is acted on | `src/approval/service.ts` |
| Timing attacks on key comparison | Constant-time comparison for every credential | `constantTimeEquals` |
Expand All @@ -48,7 +51,10 @@ CHARTER enforces limits only on trades that go through it. For that to mean anyt
- **Denial of service.** There is no rate limiting on the API beyond what the mandate rules impose on trading.
- **Transport security.** The server speaks plain HTTP. Put it behind TLS if it is reachable beyond localhost.
- **Testnet only.** Everything here has been exercised against Binance Spot Testnet. The mainnet execution path has not been run.
- **Simulation is an estimate.** It walks a sampled order book and can understate the impact of a large order; it now says so when the depth ran out, but it does not predict moves between simulation and fill.
- **Simulation is an estimate.** It walks a sampled order book and can understate the impact of a large order; it says so when the depth ran out, but it does not predict moves between simulation and fill.
- **A halt does not undo what already happened.** It stops new proposals and, with `--cancel-open`, cancels resting orders. An order that has already filled stays filled.
- **Resting orders are only counted, not watched.** A limit order can fill long after it was approved, at a market the rules never saw. The open-order cap and the spend caps bound this, and it can be cancelled, but nothing re-evaluates it while it rests.
- **Portfolio value depends on prices the exchange reports.** Holdings with no price route are excluded from NAV and listed, so a portfolio with a lot of unpriceable assets is undervalued for the drawdown check.

## Assumptions worth stating

Expand Down
10 changes: 9 additions & 1 deletion skillhub-pr/skills/charter/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ Response:
}
```

Set `"execute": true` to have CHARTER place the real order immediately when the verdict is `PASS`, or when you are supplying human confirmation for an `ESCALATE`. Leave it `false` for a dry-run verdict only.
Set `"execute": true` to have CHARTER place the real order immediately when the verdict is `PASS`. Leave it `false` for a dry-run verdict only. `execute` has no effect on an `ESCALATE`, which always waits for a person to approve it.

A LIMIT order is sized by `quantity` and `limitPrice` instead of `usd`:

```json
{ "mandateId": "<mandate-id>", "symbol": "BTCUSDT", "side": "BUY", "type": "LIMIT", "quantity": 0.001, "limitPrice": 78000 }
```

If your instance issues per-agent keys, send yours as `X-Charter-Api-Key`. Your identity then comes from the key, so `agentId` can be left out, and if you send one it must match. A key only works for the mandates it was registered with.

### charter.status

Expand Down
43 changes: 40 additions & 3 deletions src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from "../approval/service.js";
import type { ApprovalStatus } from "../approval/state.js";
import { authenticateAgent, constantTimeEquals, registryInUse } from "../agents/service.js";
import { activeVenue } from "../venues/index.js";
import { cancelAllCharterOrders, cancelCharterOrder, listOpenCharterOrders } from "../execution/orders.js";
import type { AgentRecord } from "../agents/state.js";

const ProposeBodySchema = z.object({
Expand All @@ -30,13 +32,27 @@ const ProposeBodySchema = z.object({
mandateId: z.string().uuid(),
symbol: z.string().min(1),
side: z.enum(["BUY", "SELL"]),
usd: z.number().positive(),
type: z.enum(["MARKET", "LIMIT"]).optional().default("MARKET"),
/** MARKET: the amount in USD. */
usd: z.number().positive().optional(),
/** LIMIT: how much of the asset, and the price. */
quantity: z.number().positive().optional(),
limitPrice: z.number().positive().optional(),
reason: z.string().optional(),
execute: z.boolean().optional().default(false),
}).superRefine((b, ctx) => {
if (b.type === "MARKET" && b.usd === undefined && b.quantity === undefined) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A MARKET proposal needs usd (or quantity)" });
}
if (b.type === "LIMIT") {
if (b.quantity === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal needs quantity" });
if (b.limitPrice === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal needs limitPrice" });
if (b.usd !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A LIMIT proposal is sized by quantity, not usd" });
}
});

const DecisionBodySchema = z.object({ note: z.string().optional() });
const HaltBodySchema = z.object({ reason: z.string().optional() });
const HaltBodySchema = z.object({ reason: z.string().optional(), cancelOpen: z.boolean().optional().default(false) });

/** Routes that decide something (approve, reject, halt, resume) rather than propose it. */
function isApproverRoute(path: string): boolean {
Expand Down Expand Up @@ -163,6 +179,10 @@ export function startApiServer(): Server {
res.status(404).json({ error: err.message });
return;
}
if (err instanceof z.ZodError) {
res.status(400).json({ error: "Invalid proposal", issues: err.issues });
return;
}
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
});
Expand Down Expand Up @@ -258,7 +278,24 @@ export function startApiServer(): Server {
res.status(400).json({ error: "Invalid body", issues: body.error.issues });
return;
}
res.json({ killSwitch: await engageKillSwitch(approver(req), body.data.reason) });
const killSwitch = await engageKillSwitch(approver(req), body.data.reason);
// Optionally pull every resting CHARTER order off the book as well, since a
// halt stops new proposals but does not by itself stop an order already resting.
const cancelled = body.data.cancelOpen ? await cancelAllCharterOrders(activeVenue, approver(req), body.data.reason ?? "kill switch") : undefined;
res.json({ killSwitch, ...(cancelled ? { cancelled: cancelled.cancelled.length, failed: cancelled.failed } : {}) });
});

app.get("/control/orders", async (_req: Request, res: Response) => {
res.json(await listOpenCharterOrders(activeVenue));
});

app.post("/control/orders/:symbol/:orderId/cancel", async (req: Request, res: Response) => {
try {
await cancelCharterOrder(activeVenue, req.params.symbol as string, req.params.orderId as string, approver(req));
res.json({ cancelled: req.params.orderId });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
});

app.post("/control/resume", async (req: Request, res: Response) => {
Expand Down
2 changes: 2 additions & 0 deletions src/audit/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type AuditEventType =
| "EXECUTION_ATTEMPTED"
| "EXECUTION_CONFIRMED"
| "EXECUTION_FILLED"
| "EXECUTION_PLACED"
| "ORDER_CANCELLED"
| "EXECUTION_REJECTED_BY_PLATFORM"
| "APPROVAL_REQUESTED"
| "APPROVAL_GRANTED"
Expand Down
28 changes: 27 additions & 1 deletion src/cli/commands/control.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { userInfo } from "node:os";
import { approve, engageKillSwitch, getKillSwitch, listApprovals, reject, releaseKillSwitch } from "../../approval/service.js";
import type { ApprovalStatus } from "../../approval/state.js";
import { activeVenue } from "../../venues/index.js";
import { cancelAllCharterOrders, cancelCharterOrder, listOpenCharterOrders } from "../../execution/orders.js";

/** The CLI runs with local access to the audit log, so the operator's OS username is the default identity. */
function operator(explicit?: string): string {
Expand Down Expand Up @@ -39,10 +41,34 @@ export async function rejectCommand(approvalId: string, approver: string | undef
console.log(`Rejected by ${who}. Nothing was placed.`);
}

export async function haltCommand(by: string | undefined, reason?: string): Promise<void> {
export async function haltCommand(by: string | undefined, reason?: string, cancelOpen = false): Promise<void> {
const who = operator(by);
await engageKillSwitch(who, reason);
console.log(`Kill switch ENGAGED by ${who}. Every proposal is now vetoed until it is released.`);
if (cancelOpen) {
const { cancelled, failed } = await cancelAllCharterOrders(activeVenue, who, reason ?? "kill switch");
console.log(`Cancelled ${cancelled.length} resting CHARTER order${cancelled.length === 1 ? "" : "s"}.`);
for (const f of failed) console.log(` could not cancel ${f.order.symbol} ${f.order.orderId}: ${f.error}`);
} else {
console.log("Any order already resting on the book stays there. Add --cancel-open to pull them.");
}
}

export async function ordersCommand(): Promise<void> {
const orders = await listOpenCharterOrders(activeVenue);
if (orders.length === 0) {
console.log("No CHARTER orders are resting on the book.");
return;
}
for (const o of orders) {
console.log(`${o.symbol} ${o.orderId} ${o.side} ${o.origQty} @ ${o.price} ${o.status} filled ${o.executedQty}`);
}
}

export async function cancelCommand(symbol: string, orderId: string, by: string | undefined, reason?: string): Promise<void> {
const who = operator(by);
await cancelCharterOrder(activeVenue, symbol.toUpperCase(), orderId, who, reason);
console.log(`Cancelled ${symbol.toUpperCase()} order ${orderId}. Recorded as cancelled by ${who}.`);
}

export async function resumeCommand(by: string | undefined): Promise<void> {
Expand Down
25 changes: 23 additions & 2 deletions src/cli/commands/propose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@ import { runProposal } from "../../policy/runProposal.js";
export interface ProposeOptions {
symbol: string;
side: "BUY" | "SELL";
usd: number;
type: "MARKET" | "LIMIT";
usd?: number;
quantity?: number;
limitPrice?: number;
mandateId: string;
agentId: string;
execute: boolean;
}

export async function proposeCommand(opts: ProposeOptions): Promise<void> {
console.log(`\n${opts.agentId} wants to ${opts.side} $${opts.usd} of ${opts.symbol}`);
if (opts.type === "LIMIT") {
console.log(`\n${opts.agentId} wants a limit ${opts.side} of ${opts.quantity} ${opts.symbol} at ${opts.limitPrice}`);
} else {
console.log(`\n${opts.agentId} wants to ${opts.side} $${opts.usd} of ${opts.symbol}`);
}
console.log("Simulating against live order book...");

const { verdict, execution, approval } = await runProposal({
agentId: opts.agentId,
mandateId: opts.mandateId,
symbol: opts.symbol,
side: opts.side,
type: opts.type,
usd: opts.usd,
quantity: opts.quantity,
limitPrice: opts.limitPrice,
reason: "CLI propose command",
execute: opts.execute,
});
Expand All @@ -28,6 +38,9 @@ export async function proposeCommand(opts: ProposeOptions): Promise<void> {
` reference price: ${s.referencePrice} projected fill: ${s.projectedFillPrice.toFixed(2)} ` +
`slippage: ${s.projectedSlippageBps.toFixed(1)}bps NAV impact: ${s.projectedNavImpactPct.toFixed(3)}%`
);
if (s.restingUsd > 0) {
console.log(` $${s.restingUsd.toFixed(2)} would not fill at once and would rest on the book at the limit price.`);
}
if (s.liquidityInsufficient) {
console.log(
` WARNING: sampled order-book depth could not fully cover this notional (unfilled: $${s.unfilledUsd.toFixed(2)}). ` +
Expand All @@ -46,6 +59,14 @@ export async function proposeCommand(opts: ProposeOptions): Promise<void> {
return;
}

if (execution && (execution.status === "NEW" || execution.status === "PARTIALLY_FILLED")) {
console.log(
`\nPlaced and resting: orderId=${execution.orderId} status=${execution.status} executedQty=${execution.executedQty}. ` +
`It can still fill later. Cancel it with: charter cancel ${execution.symbol} ${execution.orderId}`
);
return;
}

if (execution) {
console.log(`\nFilled: orderId=${execution.orderId} status=${execution.status} executedQty=${execution.executedQty} quoteQty=${execution.cummulativeQuoteQty}`);
return;
Expand Down
4 changes: 2 additions & 2 deletions src/cli/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ function verdictLine(e: AuditEntry): { text: string; color: string } | null {
}

function fillLine(e: AuditEntry): string | null {
if (e.type !== "EXECUTION_FILLED") return null;
if (e.type !== "EXECUTION_FILLED" && e.type !== "EXECUTION_PLACED") return null;
const p = e.payload as { orderId: string; symbol: string; side: string; executedQty: number; notionalUsd: number };
return `#${e.seq} ${e.timestamp.slice(11, 19)} FILLED ${p.side} ${p.executedQty} ${p.symbol} ($${p.notionalUsd.toFixed(2)}) order ${p.orderId}`;
return `#${e.seq} ${e.timestamp.slice(11, 19)} ${e.type === "EXECUTION_PLACED" ? "RESTING" : "FILLED "} ${p.side} ${p.executedQty} ${p.symbol} ($${p.notionalUsd.toFixed(2)}) order ${p.orderId}`;
}

const App: React.FC = () => {
Expand Down
18 changes: 17 additions & 1 deletion src/execution/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export async function executeProposal(
await auditLog.append("EXECUTION_ATTEMPTED", venue.name, {
proposalId: proposal.id,
verdictId: verdict.id,
agentId: proposal.agentId,
symbol: proposal.symbol,
order,
notionalUsd: verdict.simulation.notionalUsd,
...(approval ? { approvalId: approval.approvalId, approvedBy: approval.approver } : {}),
Expand All @@ -55,9 +57,23 @@ export async function executeProposal(
throw err;
}

await auditLog.append("EXECUTION_FILLED", venue.name, {
// An order that ended with nothing executed (expired, cancelled) committed nothing.
const stillOpen = result.status === "NEW" || result.status === "PARTIALLY_FILLED";
if (!stillOpen && result.status !== "FILLED" && result.executedQty === 0) {
await auditLog.append("EXECUTION_REJECTED_BY_PLATFORM", venue.name, {
proposalId: proposal.id,
verdictId: verdict.id,
error: `Order ended ${result.status} with nothing executed`,
});
return result;
}

// A resting limit order has not filled, but it still can, so it is logged as
// placed and counted against the caps the same way a fill is.
await auditLog.append(stillOpen ? "EXECUTION_PLACED" : "EXECUTION_FILLED", venue.name, {
proposalId: proposal.id,
verdictId: verdict.id,
agentId: proposal.agentId,
orderId: result.orderId,
symbol: result.symbol,
side: result.side,
Expand Down
Loading
Loading