← Заметки
Журнал, который нельзя подделать
architecture·audit·security

Журнал, который нельзя подделать

Каждое движение капитала, каждый апрув и каждая смена режима пишутся в журнал с хеш-цепочкой. Зачем это нужно в трейдинг-софте и как проверить целостность за полминуты.

· Mikhail Savchenko

В финансовом софте есть один сорт багов страшнее остальных: не падение, а тихая поломка. Алгоритм продолжает торговать, дашборд по-прежнему рисует кривую, а в фоне кто-то — баг, гонка, ошибка сериализации — переписал прошлую запись задним числом. Поймать такую поломку через неделю нельзя: вы не знаете, какие цифры были настоящими и какие подделанными.

Аудит-журнал в inite.fund устроен так, чтобы исключить ровно этот класс сбоев. Не нашуметь после факта, а в принципе не позволить переписать прошлое.

Append-only по конструкции

Все таблицы аудита — audit_log, capital_flows, mode_history, hil_decisions — работают по принципу «только дописывать». UPDATE по этим таблицам запрещён на уровне триггеров БД. DELETE разрешён только для самой свежей записи и только если она помечена как pending. Старше суток ни одна строка не может быть изменена даже админом.

Звучит как ограничение, но на практике это освобождение. Если строка в журнале однажды записалась — она там навсегда. Никаких «а тут мы поправили вчерашнее значение, потому что увидели опечатку». Опечатка живёт рядом с исправлением, и обе видны.

Хеш-цепочка

Каждая строка журнала хранит, помимо данных, два дополнительных поля: prev_hash и row_hash. row_hash — это SHA-256 от конкатенации всех значимых полей текущей строки плюс prev_hash предыдущей. То есть каждая запись подписывает не только себя, но и всё, что было до неё в этом журнале.

Дальше арифметика простая. Если кто-то меняет одну строку в середине, у неё ломается row_hash. Чтобы это спрятать, нужно пересчитать все row_hash после неё. Чтобы пересчитать — нужно поднять флаги триггеров на уровне БД. Чтобы их поднять — нужен суперпользователь postgres. И каждое такое поднятие пишется в системный аудит postgres, который мы тоже храним.

Цепочку можно проверить любым внешним инструментом за один запрос. Берём первую строку журнала, считаем её row_hash руками, сравниваем с записанной. Берём следующую, считаем уже с учётом prev_hash, сравниваем. До конца. Если хоть одна не сошлась — журнал тронули. Скрипт укладывается в 20 строк, мы его запускаем в каждом ночном бэкапе.

Идемпотентность через audit_ref

У всех капитальных операций — move_to_long, move_to_trading, approve_trade, set_mode — обязателен audit_ref. Это уникальный строковый ключ, который генерирует вызывающая сторона ещё до запроса. БД проверяет, что записи с таким ref ещё нет, и только тогда применяет операцию.

Зачем это нужно. Сетевой сбой посередине вызова — обычное дело. Клиент отправил запрос, БД его применила, ответ не дошёл, клиент ретраит. Без audit_ref второй вызов проходит, и одна и та же ребалансировка случается дважды. С audit_ref второй вызов получает ошибку «уже применено» с той же row_hash, что и первый, и клиент видит, что ничего нового делать не нужно.

В терминах последствий это значит, что капитальные операции в inite.fund безопасны для ретраев на любом уровне. Можно ретраить из сетевого слоя, из MCP-клиента, из ручного скрипта оператора — журнал останется консистентным.

Что это даёт оператору

Три вещи. Первое — полная история движения средств с разбиением по причинам и инициаторам. На любую цифру в дашборде вы можете перейти к конкретной строке аудита и увидеть, какой вызов её породил, когда, от чьего имени и с каким обоснованием.

Второе — внешняя проверяемость. Если когда-то понадобится показать регулятору, аудитору или партнёру, что данные не правились задним числом, у нас на это есть скрипт и формат экспорта. Это не «доверьтесь нам, мы хорошие». Это математическое свойство журнала, которое можно проверить независимо.

Третье, и для нас самое важное, — психологическая разгрузка. Когда вы знаете, что любая операция оставила след и след не подделывается, кончается отдельный класс беспокойства. Не нужно держать в голове, что система могла по-тихому решить за вас и спрятать решение. Если в журнале пусто — действия не было. Если в журнале есть запись — она точно такая, какая была в момент совершения.

Какой ценой

Каждая запись в журнал — это плюс один INSERT, плюс один SHA-256, плюс одна проверка уникальности audit_ref. По нашим замерам это 40-60 микросекунд на операцию. Для торгового движка с тактом 200 миллисекунд между тиками это шум. Для нагруженного брокерского API было бы критично, но мы не брокер.

Хранение растёт примерно на 4-6 КБ на сделку. За год живой работы торговой стратегии — порядка 20-40 МБ журнала. Для портфельной части на порядок меньше, потому что ребаланс раз в неделю. Это бесплатно по любой шкале современных дисков.

В сумме: операционная стоимость близка к нулю, выигрыш — невозможность тихо переписать прошлое. Редкий случай в архитектуре, когда не приходится выбирать.

— inite team

Похожие заметки
Все заметки →