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
39 changes: 35 additions & 4 deletions docs/fiqh-basis.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ account level is the operator's obligation, listed first in

### The remaining rails — prudential, not fiqh

Nineteen rails exist (1–14, 16, 17, 18, 19, 20 — there is no rail 15). Of these, only rail 17
encodes a fiqh ruling, and rails 1/18/19 enforce what the screen and the charter admit. The
rest are PRUDENTIAL — risk and discipline, justified by trading evidence, carrying no
religious claim:
Twenty rails exist (1–14, 16–21 — there is no rail 15). Of these, rails 17 and 21 encode a
fiqh ruling, and rails 1/18/19 enforce what the screen and the charter admit. The rest are
PRUDENTIAL — risk and discipline, justified by trading evidence, carrying no religious
claim:

| rail | what it does | basis |
| --- | --- | --- |
Expand All @@ -155,6 +155,37 @@ religious claim:
| 13, 14 | spend only the settled quote currency; monthly allowance cap | operational safety |
| 20 | trade-scope veto on live entries for a venue credential nobody has attested for trading | operational safety |

**Rail 21 is the second fiqh rail, and it was added because the ruling had a hole under it
(#667).** keel's refusal to go short is structural — no rule can express a short and the engine
builds every entry as a BUY — but structure governs what keel DECIDES, not the quantity that
reaches the venue. A SELL was sized from keel's own ledger, and the ledger runs high: a venue
that takes its taker fee out of the received base leaves less than the order said, a partial
fill leaves less still, and an operator who moves coins out of the account tells keel nothing.
Ask a venue for base that is not there and a cash account rejects it — but a margin-enabled one
fills the difference by opening a short.

That is *bay' ma la yamlik* (بيع ما لا يملك), "do not sell what you do not possess" — Ayub
Ch 6.5.1 (§65.4), with Ch 5.4.2 (§65.11) recording that "short-selling has been prohibited by
almost all scholars" because the subject matter must be "capable of ownership/title, capable of
delivery/possession". It is a more direct anchor than riba for this particular failure, and the
difference is load-bearing: the oversell is impermissible before any interest is charged, so a
riba-framed defence does not reach it at all.

Two mechanisms, deliberately split. `executor._clamp_to_held` reduces an order that is too big
for a position that really exists — down only, never up. Rail 21 refuses the one case the clamp
will not touch: a venue that affirmatively reports holding nothing while the ledger expects
something. Neither cancels a protective order, which is why
`_record_observed_fill_quantity`'s refusal to auto-resize still stands beside them unchanged.

⚠️ The rail fails **OPEN** on an unknown holding, the deliberate inverse of rails 12/13/17. A
refused BUY costs nothing; a refused SELL strands a position that wanted out. An unreadable
balance is not evidence the position is gone, and this is the one place in the engine where
"unknown is a rejection" would do more harm than the hole it closes.

What remains open at the venue boundary is #666: on a cash account every case above is a
rejected order rather than a short, and keel has no cash-account posture check on Coinbase —
`verify_cash_account` exists only on the Alpaca adapter.

Beside the rails — not among them, and not numbered — sits one routing-time check with the
same prudential character: the **max-spread entry gate** (#350, `keel/execution/executor.py`)
refuses a live BUY whose previewed book shows `(best_ask − best_bid) / mid` at or beyond
Expand Down
2 changes: 1 addition & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Each entry is a `## term` heading, a definition, and a `Source:` line.
## rail

One of keel's numbered hard guards that every order passes through -- spend caps, drawdown
breakers, the allowlist, settlement-currency, spot-shape and trade-scope checks. Nineteen exist
breakers, the allowlist, settlement-currency, spot-shape and trade-scope checks. Twenty exist
(1-14, 16, 17, 18, 19, 20 -- there is no rail 15); each is un-overridable and audit-logged.

Source: keel's own vocabulary -- docs/fiqh-basis.md's rails table (the prudential rails 2-14, 16, 20) plus its prose sections for rails 1, 17, 18 and 19
Expand Down
2 changes: 1 addition & 1 deletion docs/go-live-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ exists to accumulate the live evidence the promotion gate demands and cannot its
Demoting these rules would end that experiment without putting anything in its place.

**What bounds the risk instead.** Not the promotion gate, which never ran. The caps
(`max_exposure_usd` 200 total at once, `max_per_order_usd` 100), the nineteen un-overridable
(`max_exposure_usd` 200 total at once, `max_per_order_usd` 100), the twenty un-overridable
`guards.py` rails, rail 1's allowlist, and rail 14's monthly allowance. **The bypass is of the
evidence gate, not the safety rails** — separate mechanisms, and only the first was skipped.

Expand Down
2 changes: 1 addition & 1 deletion docs/launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ the Arabic README.
> I built an open-source Shariah-compliance **engine** for spot crypto trading — not a
> trading bot with a halal coat of paint, but the compliance machinery: allowlist
> admission where Shariah classifications are attested with a source and never inferred
> from market data, a fails-closed screen, nineteen un-overridable safety rails including
> from market data, a fails-closed screen, twenty un-overridable safety rails including
> §65.4 *qabd* (constructive possession) encoded as an executable check, and an
> audit trail of who attested what.
>
Expand Down
2 changes: 1 addition & 1 deletion docs/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ half it can:

The gate is BUY-only (exits must execute — the same principle that makes rail 17 halt entries,
not exits), **fails closed** (a live BUY whose preview carries no readable bid/ask is refused
with a distinct `book_unreadable` reason, never guessed past), and lives beside the nineteen
with a distinct `book_unreadable` reason, never guessed past), and lives beside the twenty
rails rather than among them: `guards.check` is broker-less by design, and the book exists only
in the preview the executor just fetched.

Expand Down
54 changes: 54 additions & 0 deletions keel/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,51 @@ def partial_fill_findings(orders: list[dict[str, Any]]) -> list[Finding]:



def balance_drift_findings(records: dict[str, Any]) -> list[Finding]:
"""Products where the venue held less base than keel's ledger expected (#667).

Written by `executor._clamp_to_held` whenever a SELL had to be reduced. The clamp already
kept the order honest -- keel asked for what was there, not what it remembered -- so this is
not a report of an order that went wrong. It is a report that the BOOKS and the ACCOUNT
disagree, which the clamp handles per-order and nobody reconciles.

WARN, not FAIL, and for a specific reason: every cause is legitimate. A venue took its fee
out of the base leg, a fill came in short, or the operator moved coins. None of those is a
fault in the deployment; all of them make the ledger's idea of the position wrong until a
human decides which it was. A FAIL would demand action on a state that may be entirely
intended.

Surfaced HERE rather than left to the log line the clamp also writes, because the drift
outlives the order that discovered it: the next exit will be clamped by the same amount, and
an operator who never greps for `executor.sell_clamped_to_held` would never learn why.
"""
drifts = sorted((p, r) for p, r in records.items() if isinstance(r, dict))
if not drifts:
return [
Finding(
"balance.drift",
OK,
"no ledger/venue divergence recorded",
"every SELL went out at the quantity the ledger expected",
"-",
)
]
described = ", ".join(
f"{product}: ledger {record.get('ordered')} vs venue {record.get('held')} "
f"(short {record.get('drift')})"
for product, record in drifts
)
return [
Finding(
"balance.drift",
WARN,
f"{len(drifts)} product(s) held less than the ledger expected",
f"{described} -- the SELL was clamped to the held quantity, so nothing oversold",
"reconcile the position: a base-leg fee, a short fill, or an out-of-band transfer",
)
]


def unbooked_exit_findings(
open_positions: list[dict[str, Any]], orders: list[dict[str, Any]]
) -> list[Finding]:
Expand Down Expand Up @@ -938,6 +983,7 @@ def gather_findings(repo: Any, config: Any, log_lines: Iterable[str], now_ts: in
from keel import agent
from keel.commands import fetch
from keel.commands._products import _default_sim_products
from keel.execution import executor as executor_mod
from keel.execution import guards

findings: list[Finding] = []
Expand Down Expand Up @@ -983,6 +1029,14 @@ def gather_findings(repo: Any, config: Any, log_lines: Iterable[str], now_ts: in
findings += veto_findings(log_lines, since_ts=now_ts - 7 * 86_400)
# A repo read, like every other check -- pinned read-only by the same change-counter test.
findings += partial_fill_findings(repo.get_orders(mode="live"))
# Read here rather than inside the finding, so the finding stays a pure function of data
# like every other one in this module and the read stays where the repo already is.
findings += balance_drift_findings(
{
key[len(executor_mod.BALANCE_DRIFT_PREFIX) :]: repo.get_state(key)
for key in repo.get_state_keys(executor_mod.BALANCE_DRIFT_PREFIX)
}
)
# #639: modes are POOLED here, unlike the partial-fill sweep above -- the ledger
# invariant belongs to `agent._open_tranche`, which writes it for paper and live alike.
findings += unbooked_exit_findings(repo.get_open_positions(), repo.get_orders())
Expand Down
Loading