Как человечество научилось рассуждать: от Аристотеля до программ и категорий
Силлогизм — это рассуждение, в котором из принятых посылок с необходимостью следует нечто отличное от них.
Аристотель, «Первая аналитика», в современном пересказе
Представьте, что в организации появляется простое правило:
Сотрудник может открыть отчёт, если ему это разрешено.
В первый день все кивают. На второй начинаются вопросы. Кто считается сотрудником? Разрешение выдаётся на один отчёт или на все? Что делать с временным доступом? Может ли руководитель разрешить доступ самому себе? Что произойдёт, если человека уволили, а разрешение осталось?
История логики начинается именно в таких местах. Люди формулируют понятное правило, сталкиваются с пограничным случаем и вынуждены точнее назвать слова, основания и переход к выводу. Каждая следующая теория в этой главе появляется не ради сложности, а потому, что предыдущего инструмента уже не хватает.
Мы будем всё время возвращаться к одному вопросу: что пришлось сделать явным, чтобы решение перестало зависеть от догадки?
Допустим, команда Atlas получает требование:
Авторизованный пользователь может открыть отчёт.
Это ещё не спецификация. Здесь смешаны идентичность, состояние аккаунта, роль,
отношение пользователя к ресурсу, свежесть аутентификации, область арендатора,
время действия разрешения и поведение системы при недоступности зависимостей.
Если сразу написать if, неявная логика распределится между контроллером,
React-компонентом, SQL-запросом и middleware.
Инженерная редакция главы проследит один и тот же путь:
неясное требование
→ аргумент и скрытые посылки
→ доменные понятия
→ предикаты и таблица решений
→ типы и инварианты
→ исполнимая политика
→ эффекты и отказы
→ архитектурная граница
Мы не будем переводить один if на шесть языков. C# и Java покажут
объектную модель и спецификации, TypeScript и JavaScript — алгебраические типы
и композицию предикатов, Python — декларативную политику и свойства, Elixir —
сопоставление с образцом, конвейер и явные ошибки. Это разные способы думать о
правиле, а не витрина синтаксиса.
1. Сначала люди учились не доказывать, а убеждать
На встрече руководитель предлагает: «Давайте дадим всем менеджерам доступ. Они отвечают за результат и должны видеть отчёты». Фраза звучит разумно. В ней есть мотив, уверенный голос и понятная польза. Но из ответственности за результат не следует доступ ко всем данным.
Между причиной и решением спрятаны предположения: каждому менеджеру нужны одинаковые сведения; отчёты не содержат лишнего; риск утечки ниже пользы; слово «менеджер» однозначно определяет круг людей. Пока предположения не названы, согласие аудитории легко принять за доказательство.
Древнегреческая традиция постепенно разделила три задачи. Риторика изучает, как речь убеждает. Диалектика проверяет позицию вопросами и возражениями. Логика спрашивает, действительно ли вывод поддержан основаниями.
Первый практический навык логики очень простой: остановиться между «мне хочется согласиться» и «это действительно следует». Услышав «этот вариант покупают чаще, значит он лучше», разделите популярность товара и его пригодность именно для вас. Первое может быть правдой, но второе требует нового основания.
Архитектурные обсуждения тоже начинаются с риторики:
Пользователи жалуются на скорость, поэтому перепишем сервис на Elixir.
Elixir может быть отличным выбором, но посылка описывает симптом, а вывод — конкретное и дорогое решение. Между ними отсутствуют профиль задержек, граница узкого места, модель нагрузки, стоимость миграции и критерий успеха. Даже технически верное утверждение «BEAM хорошо работает с большим числом лёгких процессов» не доказывает, что задержка возникла из-за модели конкурентности.
Инженерный аргумент полезно раскладывать как ADR:
Наблюдение: p95 открытия отчёта вырос с 280 мс до 1,8 с.
Причина: 74% времени занимает последовательный вызов трёх policy-сервисов.
Ограничение: нельзя ослаблять аудит и изоляцию арендаторов.
Варианты: параллельные вызовы, локальный снимок политики, смена протокола,
перенос вычисления или переписывание сервиса.
Решение: ...
Проверка: p95 < 500 мс при 2 000 RPS, решения совпадают с эталоном.
Так логика входит в архитектуру раньше кода. RFC, ADR и design review нужны не для бюрократии, а для восстановления моста между фактом и решением. Хороший документ отделяет измерение от интерпретации, ограничение от предпочтения и обратимое решение от необратимого.
Инженерное правило: технология не является выводом из проблемы, пока не назван механизм, через который она меняет наблюдаемый результат.
Люди научились различать убедительность и следование. Следующая задача сложнее: как увидеть одинаковую форму в историях с разными героями?
2. Аристотель отделяет форму от содержания
Возьмём рассуждение:
Все владельцы отчёта имеют право его открыть.
Мира — владелец отчёта.
Следовательно, Мира имеет право открыть отчёт.
Заменим содержание буквами:
Все M являются P.
S является M.
Следовательно, S является P.
Исчезли Мира и отчёт, но сохранился каркас. Аристотель сделал этот каркас предметом исследования. Валидность относится к форме: при истинных посылках правильный переход не может дать ложный вывод. Она не гарантирует, что сами посылки верны.
В обычной речи часть рассуждения часто пропускают: «У Миры есть служебная карта, значит отчёт можно открыть». Мы сами добавляем правило «владелец карты имеет доступ». Такое сокращённое рассуждение называют энтимемой. Без энтимем разговор был бы невыносимо подробным, но именно в пропущенной посылке обычно и прячется разногласие.
Поэтому при споре полезно не повторять вывод громче, а спросить: «Какое общее правило соединяет этот факт с решением?»
Требование «пользователь может экспортировать отчёт» — инженерная энтимема. В
нём пропущено общее правило: какой пользователь, какой отчёт, в каком tenant,
при каком состоянии сессии и что означает «может» — увидеть кнопку, получить
200 OK или завершить асинхронный экспорт?
ООП полезно здесь не потому, что «всё является объектом», а потому, что модель
может закрепить понятия и запретить случайные подстановки. В C# начнём не с
User.IsAdmin, а с разных идентичностей и явного решения:
public readonly record struct EmployeeId(Guid Value);
public readonly record struct ReportId(Guid Value);
public readonly record struct TenantId(Guid Value);
public sealed record AccessRequest(
EmployeeId EmployeeId,
ReportId ReportId,
TenantId TenantId,
DateTimeOffset RequestedAt);
public abstract record AccessDecision
{
public sealed record Allowed(string PolicyVersion) : AccessDecision;
public sealed record Denied(DenialReason Reason) : AccessDecision;
}
Отдельные типы не доказывают право доступа, зато не дают перепутать
EmployeeId и ReportId. Иерархия решения заставляет вызывающий код разобрать
не только true, но и причину отказа. Это современная версия аристотелевского
шага: убрать сюжет и назвать форму операции.
Затем восстанавливаем скрытые посылки как доменные утверждения:
1. Employee активен в Tenant.
2. Report принадлежит тому же Tenant.
3. Employee является владельцем Report или имеет роль Auditor в его области.
4. Для чувствительного Report сессия должна быть повторно подтверждена.
5. Явный запрет сильнее разрешающей роли.
В Java тот же приём удобно выразить record для значений и sealed interface
для конечного набора решений. Смысл не в языке: мы превращаем разговорные
существительные в типы, а скрытый переход — в контракт. После этого спор
«админ же всё может» становится конкретным вопросом к политике.
Антипаттерн: объект User с десятками флагов и методом
CanDoEverything() не моделирует область — он консервирует энтимему внутри
универсального контейнера.
Силлогизм хорошо работает с классами и свойствами. Но правила доступа состоят ещё из «если», «и», «или» и «не». Для них нужен другой ракурс.
3. Стоики переходят от вещей к условиям
Рассмотрим вывод:
Если учётная запись заблокирована, отчёт открыть нельзя.
Учётная запись Миры заблокирована.
Следовательно, Мира не может открыть отчёт.
Здесь важен не класс, к которому относится Мира, а связь между целыми высказываниями: «если P, то Q; P; следовательно, Q». Стоические логики исследовали такие формы задолго до компьютеров.
Этот язык сразу обнаруживает частую ошибку. Если из блокировки следует запрет, то из самого запрета ещё не следует блокировка. Доступ мог быть запрещён из-за истёкшего разрешения или неподходящего отчёта. Мы часто путаем достаточную причину с единственно возможной.
В быту это выглядит так: «если идёт дождь, дорога мокрая». Увидев мокрую дорогу, нельзя уверенно заключить, что был дождь: могла проехать поливальная машина. Логическая форма помогает не придумывать причину по одному следствию.
Логика высказываний — это фундамент guard clauses, feature flags, firewall
rules и условий маршрутизации. Но программисту важно различать импликацию и
обычное ветвление. Правило blocked → denied не означает denied → blocked.
Если API возвращает только 403, клиент не может восстановить причину.
На JavaScript полезно сначала сделать решение явным, а уже потом сокращать:
function decideAccess(context) {
if (context.user.blocked) {
return { kind: "denied", reason: "account-blocked" };
}
if (context.report.tenantId !== context.user.tenantId) {
return { kind: "denied", reason: "tenant-mismatch" };
}
if (!context.user.isOwner && !context.user.roles.includes("auditor")) {
return { kind: "denied", reason: "missing-grant" };
}
return { kind: "allowed" };
}
Это длиннее булевого выражения, зато порядок правил и приоритет запрета видны.
Если причины нужны для аудита, boolean уже слишком бедный тип результата.
Теперь проверим форму таблицей решений. Для каждого фактора выбираем эквивалентный класс, а не перебираем весь мир:
| Заблокирован | Тот же tenant | Владелец или аудитор | Решение |
|---|---|---|---|
| 1 | любое | любое | deny: account-blocked |
| 0 | 0 | любое | deny: tenant-mismatch |
| 0 | 1 | 0 | deny: missing-grant |
| 0 | 1 | 1 | allow |
Таблица выявляет семантику короткого замыкания. Это уже архитектурное решение: если сначала запросить удалённый сервис ролей, а потом проверить локальную блокировку, система тратит сеть и может утечь через различия во времени ответа.
Законы де Моргана помогают ревьюить отрицания:
НЕ (owner ИЛИ auditor) = НЕ owner И НЕ auditor
НЕ (owner И auditor) = НЕ owner ИЛИ НЕ auditor
Ошибки здесь регулярно переживают code review, особенно когда позитивная бизнес-фраза превращается в несколько отрицательных guard clauses.
Правила условий всё ещё записывались словами. Следующий шаг — сделать их объектом вычисления.
4. Лейбниц мечтает о вычислении, Буль строит алгебру
Лейбниц мечтал о языке, в котором спор можно было бы разрешить проверкой символов: «Давайте вычислим». Полностью такой язык он не построил, но идея оказалась плодотворной. В XIX веке Джордж Буль описал логические отношения как алгебру.
Наше правило превращается в выражение:
доступ = (владелец ИЛИ аудитор)
И повторная_проверка
И НЕ заблокирован
Формула не решает, справедливо ли правило. Она заставляет одинаково обработать одинаковые случаи. Стоит выписать четыре-пять строк таблицы, и становятся видны вопросы, которые прятались в слове «обычно».
Так можно проверять льготу, страховку или право на возврат товара. Формализация полезна не тем, что заменяет человека, а тем, что не позволяет незаметно менять условия во время рассуждения.
Булева алгебра даёт вычислимую форму, но инженерная задача только начинается.
В TypeScript лучше вернуть алгебраический тип решения, а не протащить boolean
через всю систему:
type DenialReason =
| "account-blocked"
| "tenant-mismatch"
| "reauth-required"
| "missing-grant";
type AccessDecision =
| { kind: "allowed"; policyVersion: string }
| { kind: "denied"; reason: DenialReason };
type AccessFacts = Readonly<{
blocked: boolean;
sameTenant: boolean;
owner: boolean;
auditor: boolean;
reauthenticated: boolean;
}>;
function decide(facts: AccessFacts): AccessDecision {
if (facts.blocked) return { kind: "denied", reason: "account-blocked" };
if (!facts.sameTenant) return { kind: "denied", reason: "tenant-mismatch" };
if (!facts.reauthenticated) return { kind: "denied", reason: "reauth-required" };
if (!facts.owner && !facts.auditor) {
return { kind: "denied", reason: "missing-grant" };
}
return { kind: "allowed", policyVersion: "reports/v3" };
}
Здесь функция чистая: одинаковые факты дают одинаковое решение, сеть и БД
не спрятаны внутри. Readonly не делает программу математически чистой, но
закрепляет намерение не менять вход во время вычисления.
Чистое ядро позволяет проверять не только примеры, но и свойства:
// Псевдокод property-based теста
forAll(accessFacts, (facts) => {
if (facts.blocked) {
expect(decide(facts)).toEqual({
kind: "denied",
reason: "account-blocked"
});
}
});
Полезные свойства политики:
- блокировка всегда доминирует над разрешением;
- изменение чужого tenant не может превратить deny в allow;
- добавление роли не обходится без повторной аутентификации;
- результат содержит версию правила для воспроизводимого аудита.
Это уже функциональный дизайн: данные неизменяемы, решение выражено значением, эффекты вынесены за границу. Позже мы сравним его с объектной спецификацией.
Булева формула умеет соединять готовые утверждения. Но как выразить отношения между конкретными людьми, отчётами и организациями?
5. Фреге добавляет переменные, отношения и кванторы
Фраза «Мира может открыть отчёт» говорит о двух объектах и отношении между ними. Простого «истина или ложь» недостаточно: нужно оставить места для конкретного пользователя и конкретного отчёта.
Так появляются предикаты вроде может_открыть(человек, отчёт) и кванторы:
«для каждого отчёта», «существует сотрудник». Квантор помогает увидеть опасную
подмену масштаба. Из того, что каждый сотрудник читает какой-нибудь отчёт, не
следует, что существует один отчёт, который читают все.
В исследовании это различие встречается постоянно. «Для каждого участника нашлась полезная практика» и «одна практика оказалась полезна всем» — разные утверждения. Перестановка двух слов меняет смысл результата.
Проверяйте обобщение тремя вопросами: о каких объектах идёт речь, какое между ними отношение и для всех ли случаев заявлен вывод.
Фрегеанский переход от высказывания к предикату знаком любому разработчику:
canOpen(user, report) → Decision
В Python удобно отделить факты от политики и сделать зависимости явными:
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
class Role(StrEnum):
AUDITOR = "auditor"
MANAGER = "manager"
@dataclass(frozen=True)
class User:
id: str
tenant_id: str
roles: frozenset[Role]
blocked: bool
@dataclass(frozen=True)
class Report:
id: str
tenant_id: str
owner_id: str
sensitive: bool
@dataclass(frozen=True)
class AccessContext:
user: User
report: Report
reauthenticated_at: datetime | None
def has_grant(ctx: AccessContext) -> bool:
return (
ctx.user.id == ctx.report.owner_id
or Role.AUDITOR in ctx.user.roles
)
Теперь кванторы проявляются в API коллекций:
all(can_open(user, report) for report in reports) # ∀ report
any(can_open(user, report) for report in reports) # ∃ report
Подмена all на any — не мелкая ошибка цикла, а изменение логического
утверждения. В SQL та же разница скрывается между NOT EXISTS и EXISTS, а в
ORM может потеряться за удобным именем метода.
Ещё важнее область действия квантора. Проверка «пользователь имеет роль аудитора в какой-нибудь организации» не равна «пользователь является аудитором организации этого отчёта». Предикат должен включать ресурсную область:
def is_auditor_for(user: User, report: Report) -> bool:
return (
user.tenant_id == report.tenant_id
and Role.AUDITOR in user.roles
)
На архитектурном уровне это защита от confused deputy и cross-tenant access. Роль без области — почти всегда неполная модель. Если политика переводится в SQL, фильтр tenant должен быть частью запроса, а не постфильтрацией после загрузки данных.
Чем точнее становился язык, тем заметнее были его собственные границы.
6. Парадоксы и формальные системы проводят границы
Наивная идея множества звучит безобидно: можно собрать вместе все предметы с нужным свойством. Рассел спросил, что произойдёт с множеством всех множеств, которые не содержат сами себя. Если оно содержит себя, то не должно; если не содержит, то должно.
Парадокс показал: нельзя без ограничений превращать любое описание в объект. Нужны правила образования допустимых множеств. Позже программа Гильберта попыталась построить надёжное основание математики, а результаты Гёделя показали, что достаточно выразительная непротиворечивая формальная система не может доказать внутри себя все истинные утверждения своего языка.
Практический вывод скромнее громких лозунгов: любой метод работает внутри предпосылок. Таблица не докажет, что мы выбрали справедливые признаки. Проверка анкеты не гарантирует правдивость ответов. Строгость начинается с честного описания границы.
Инженеры часто пересказывают Гёделя как «невозможно доказать программу», но это слишком грубо. Для конкретных программ доказывают свойства, используют типы, model checking и proof assistants. Ограничение в другом: выразительность, непротиворечивость, разрешимость и полнота не выдаются одновременно бесплатно.
В прикладной модели первая линия защиты — сделать недопустимые состояния
непредставимыми. Java sealed interface ограничивает пространство principal:
public sealed interface Principal
permits Employee, ServiceAccount, Anonymous {}
public record Employee(
EmployeeId id,
TenantId tenantId,
Set<Role> roles,
AccountStatus status
) implements Principal {}
public record ServiceAccount(
ClientId id,
TenantId tenantId,
Set<Scope> scopes
) implements Principal {}
public record Anonymous() implements Principal {}
Компилятор может потребовать полный switch по вариантам. Но он не докажет,
что tenantId пришёл из доверенного источника, что роль не отозвана секунду
назад или что часы двух сервисов согласованы. Типовая модель очерчивает одну
границу доказательства, а не заменяет систему безопасности.
В C# nullable reference types отличают «значение может отсутствовать» от
случайного null, required не даёт забыть поле при инициализации, а закрытый
конструктор может сохранять инвариант:
public sealed class TemporaryGrant
{
public EmployeeId EmployeeId { get; }
public ReportId ReportId { get; }
public DateTimeOffset ExpiresAt { get; }
private TemporaryGrant(
EmployeeId employeeId,
ReportId reportId,
DateTimeOffset expiresAt)
{
EmployeeId = employeeId;
ReportId = reportId;
ExpiresAt = expiresAt;
}
public static TemporaryGrant Create(
EmployeeId employeeId,
ReportId reportId,
DateTimeOffset expiresAt,
DateTimeOffset now)
{
if (expiresAt <= now) throw new ArgumentOutOfRangeException(nameof(expiresAt));
return new(employeeId, reportId, expiresAt);
}
}
Но время передано аргументом не случайно. Если класс сам вызывает
DateTimeOffset.UtcNow, тест и доказательство свойства начинают зависеть от
неявного эффекта.
Граница формализации должна быть записана рядом с моделью:
- типы гарантируют форму данных после успешной валидации;
- политика гарантирует решение для переданного снимка фактов;
- репозитории отвечают за происхождение и свежесть фактов;
- интеграционные тесты проверяют сборку компонентов;
- мониторинг проверяет поведение уже работающей системы.
Формула описывает отношение. Компьютеру нужна процедура, которая получит данные и выполнит шаги.
7. Тьюринг превращает правило в процедуру, Шеннон — в схему
В XX веке логика встретилась с вопросом: что вообще значит «можно вычислить»? Модель Тьюринга описала простую машину, выполняющую точные шаги над символами. Шеннон показал, что булевы отношения можно воплотить электрическими реле. Рассуждение стало не только записью, но и процессом.
Однако правило и процедура не совпадают. «Возврат возможен в течение 14 дней» — правило. Кто проверяет дату, что происходит без чека, как фиксируется спор и когда возвращаются деньги — процедура. Хорошее правило может дать плохой результат, если процедура неполна.
Исполнение добавляет время и отказ. Данные могут измениться между проверкой и действием, сотрудник может ошибиться, а нужная система — не ответить. Поэтому после вопроса «правильно ли правило?» всегда нужен второй: «что произойдёт в реальном процессе?»
Чистая функция decide(facts) ничего не знает о получении фактов. Настоящий use
case читает пользователя, отчёт, роли и состояние сессии, затем пишет аудит.
Это граница между functional core и imperative shell.
Elixir делает возможные отказы видимыми через значения и pattern matching:
def open_report(user_id, report_id, now) do
with {:ok, user} <- Users.fetch(user_id),
{:ok, report} <- Reports.fetch(report_id),
{:ok, session} <- Sessions.current(user_id),
facts <- AccessFacts.from(user, report, session, now),
{:allow, policy_version} <- Policy.decide(facts),
:ok <- Audit.record(user, report, policy_version) do
Reports.open(report)
else
{:deny, reason} -> {:error, {:forbidden, reason}}
{:error, :not_found} -> {:error, :not_found}
{:error, :dependency_unavailable} -> {:error, :temporarily_unavailable}
end
end
Оператор with не «делает код функциональным» автоматически. Его ценность в
явной форме конвейера: каждый шаг возвращает значение успеха или ошибки, а
ветка else задаёт семантику отказа. Скрытый сетевой вызов внутри
Policy.decide/1 снова смешал бы вычисление и эффект.
Здесь появляется проблема TOCTOU: роль может быть отозвана после чтения, но до открытия отчёта. Возможные архитектурные ответы различны:
- принять eventual consistency и ограничить время жизни снимка;
- выполнять проверку и чтение в одной транзакционной границе;
- выдавать короткоживущий capability token на конкретный ресурс;
- версионировать политику и проверять версию на стороне ресурса;
- отправлять события отзыва и уметь закрывать уже открытые сессии.
Шенноновский взгляд полезен для оптимизации: сложная политика является схемой из логических вентилей. Можно минимизировать повторные условия, но нельзя терять диагностические причины и порядок правил. Самое короткое булево выражение не всегда лучший production-код.
Инженерное правило: сначала определите семантику отказа, затем выбирайте между fail-open и fail-closed. Для доступа к чувствительному отчёту недоступный policy service обычно означает deny или retry, а не молчаливое разрешение.
Теперь у нас есть объекты, условия, типы и процедура. Осталось понять, как организовать код, не превратив логику в один гигантский метод.
8. ООП и ФП дают разные формы одной политики
Одно правило можно объяснить двумя способами. Первый собирает вокруг сущности её состояние и допустимые действия: «отчёт сам знает, кто является владельцем». Второй рассматривает данные отдельно и применяет к ним преобразования: «получим факты и вычислим решение».
Это не спор о единственно правильном стиле. Иногда важнее объект с устойчивой историей и правилами изменения. Иногда — прозрачная цепочка вычислений, которую легко проверять отдельно. Сложные системы обычно используют оба подхода на разных границах.
Полезный вопрос звучит не «что лучше вообще?», а «где должно жить правило, какие данные ему нужны и как мы убедимся, что оно не изменилось случайно?»
ООП и ФП моделируют не разные миры, а разные центры тяжести. ООП связывает поведение с объектами и защищает инварианты. ФП делает преобразования явными, предпочитает значения и композицию функций. Политика доступа хорошо показывает сильные и слабые стороны обоих подходов.
Объектная спецификация на C#
Specification Pattern полезен, когда правила нужно именовать, комбинировать и объяснять:
public interface ISpecification<in T>
{
bool IsSatisfiedBy(T candidate);
string Code { get; }
}
public sealed class ActiveAccount : ISpecification<AccessFacts>
{
public string Code => "active-account";
public bool IsSatisfiedBy(AccessFacts facts) => !facts.User.IsBlocked;
}
public sealed class SameTenant : ISpecification<AccessFacts>
{
public string Code => "same-tenant";
public bool IsSatisfiedBy(AccessFacts facts) =>
facts.User.TenantId == facts.Report.TenantId;
}
Не стоит строить бесконечное дерево AndSpecification<OrSpecification<...>>,
если бизнесу важен порядок причин отказа. Композиция должна сохранять не только
bool, но и объяснимость.
Предикаты на Java
Java уже содержит функциональный интерфейс Predicate<T>:
Predicate<AccessFacts> active = facts -> !facts.user().blocked();
Predicate<AccessFacts> sameTenant = facts ->
facts.user().tenantId().equals(facts.report().tenantId());
Predicate<AccessFacts> hasGrant = facts ->
facts.user().id().equals(facts.report().ownerId())
|| facts.user().roles().contains(Role.AUDITOR);
Predicate<AccessFacts> canOpen = active.and(sameTenant).and(hasGrant);
Это компактно для фильтрации. Для авторизации production-уровня снова нужен богатый результат: какой предикат не прошёл, какая версия политики действовала, какие факты можно безопасно положить в аудит.
Композиция на TypeScript
Функциональные комбинаторы позволяют собирать policy as data:
type Rule<A> = (value: A) => AccessDecision;
const allow: AccessDecision = { kind: "allowed", policyVersion: "reports/v3" };
const denyWhen = <A>(
predicate: (value: A) => boolean,
reason: DenialReason
): Rule<A> => value => predicate(value)
? { kind: "denied", reason }
: allow;
const firstDenial = <A>(...rules: Rule<A>[]): Rule<A> => value => {
for (const rule of rules) {
const decision = rule(value);
if (decision.kind === "denied") return decision;
}
return allow;
};
Композиция хороша, пока тип результата сохраняет доменный смысл. Если всё
свести к A => boolean, потеряются причины и возможность эволюции контракта.
Динамическая граница на JavaScript
JavaScript удобен для простых предикатов, но вход из HTTP нельзя считать типизированным только потому, что IDE знает JSDoc:
function parseAccessRequest(input) {
if (!input || typeof input !== "object") {
return { ok: false, error: "invalid-body" };
}
if (typeof input.userId !== "string" || typeof input.reportId !== "string") {
return { ok: false, error: "invalid-identifiers" };
}
return {
ok: true,
value: Object.freeze({ userId: input.userId, reportId: input.reportId })
};
}
Валидация на границе и неизменяемое внутреннее значение важнее классов ради классов. После разбора обычный JavaScript способен поддерживать тот же functional core.
Pattern matching на Elixir
В Elixir правила естественно выражаются несколькими предложениями функции:
def decide(%{blocked: true}), do: {:deny, :account_blocked}
def decide(%{same_tenant: false}), do: {:deny, :tenant_mismatch}
def decide(%{reauthenticated: false}), do: {:deny, :reauth_required}
def decide(%{owner: false, auditor: false}), do: {:deny, :missing_grant}
def decide(_facts), do: {:allow, "reports/v3"}
Порядок предложений является частью политики. Это очень читаемо для first-match семантики, но новый разработчик должен понимать, что перестановка строк меняет результат.
Декларативные правила на Python
Python позволяет собрать правила как данные и получить трассу решения:
Rule = tuple[str, callable]
RULES: tuple[Rule, ...] = (
("account-blocked", lambda f: f.user.blocked),
("tenant-mismatch", lambda f: f.user.tenant_id != f.report.tenant_id),
("missing-grant", lambda f: not has_grant(f)),
)
def decide_with_trace(facts: AccessContext):
for reason, rejects in RULES:
if rejects(facts):
return {"kind": "denied", "reason": reason}
return {"kind": "allowed", "policy_version": "reports/v3"}
У декларативности есть цена: слабее статические гарантии, легко положить в лямбду эффект и сложнее навигировать по слишком абстрактному DSL.
Итоговый выбор обычно гибридный: доменные объекты защищают локальные инварианты, чистая функция принимает снимок фактов, а application service координирует эффекты. Это не компромисс «ни рыба ни мясо», а разделение разных видов сложности.
Теории множеств, групп и категорий помогают посмотреть на модель под разными углами. Они не образуют лестницу от простого к «самому умному».
9. Множества, инварианты и композиция — три инженерные линзы
Множества спрашивают, кто входит в рассматриваемую группу. Для доступа это сотрудники, владельцы, аудиторы и заблокированные аккаунты. Ошибка возникает, когда вывод о одной группе незаметно переносят на другую.
Преобразования и инварианты спрашивают, что можно изменить, не разрушив важного свойства. Можно переименовать роли или переставить шаги процесса, но должно ли право доступа остаться прежним?
Композиция смотрит на соединение шагов. Каждый этап по отдельности может быть разумным, но вся цепочка — ошибочной. Анкета собрана корректно, данные посчитаны верно, график построен честно, а вывод всё равно не относится к исходному вопросу.
Выбирайте линзу по проблеме. Спорят о том, кого посчитали, — начните с множеств. Сравнивают два процесса — назовите сохраняемое свойство. Ошибка возникает на стыке — исследуйте композицию.
Эти три линзы напрямую связаны с архитектурой.
Множества: RBAC, ABAC и область данных
RBAC описывает принадлежность множествам ролей. ABAC вычисляет решение из атрибутов пользователя, ресурса и окружения. Реальная система часто сочетает оба подхода: роль даёт кандидатное разрешение, tenant и классификация ресурса сужают область, явный deny исключает объект.
allowedReports(user)
= ownedReports(user)
∪ auditedReports(user.tenant)
− explicitlyDeniedReports(user)
Такое описание напоминает, что фильтрация списка и авторизация единичного ресурса должны использовать одну политику. Иначе интерфейс покажет меньше или больше данных, чем endpoint реально разрешает открыть.
Инварианты: что обязано сохраниться
При рефакторинге policy engine важны не одинаковые классы, а наблюдаемые свойства:
- cross-tenant запрос никогда не разрешается;
- отзыв роли монотонно уменьшает множество доступных отчётов;
- кеширование не меняет решение дольше допустимого окна;
- ретраи не создают дубликаты аудита;
- новая версия политики может объяснить отличие от старой.
Эти свойства подходят для property-based, differential и mutation testing. Можно прогнать старый и новый движок на записанном наборе обезличенных фактов и сравнить решения до переключения трафика.
Композиция: контракты между шагами
Архитектурная цепочка выглядит так:
HTTP request
→ Authentication
→ Tenant resolution
→ Fact loading
→ Policy decision point
→ Policy enforcement point
→ Report storage
→ Audit log
Локально корректные компоненты не гарантируют корректную композицию. Если authentication выдаёт глобальную роль, tenant resolution выбирает tenant из URL, а policy engine предполагает, что роль уже ограничена tenant, появляется уязвимость на стыке контрактов.
Категорная интуиция здесь практична: выход одного преобразования должен
соответствовать входу следующего, композиция должна сохранять законы, а
тождественное преобразование не должно менять смысл. Не обязательно писать
термины Functor и Monad в business-коде, чтобы проверять композицию.
Теперь соберём идеи не в учебный пример, а в несколько реальных архитектур.
10. Как политика живёт в разных архитектурах
В маленькой организации правило может жить в инструкции одного отдела. По мере роста появляются несколько систем, филиалы, временные сотрудники и аудит. То, что раньше решал знакомый человек, превращается в отдельный процесс.
Централизация делает правила единообразнее, но создаёт зависимость: если общий центр недоступен, работа останавливается. Копии на местах быстрее, но могут устареть. Здесь нет решения без цены — есть выбор компромисса и честное описание последствий.
Логика помогает не выбрать архитектуру автоматически, а удержать смысл правила во время роста. Кто принимает решение? На каких данных? Как долго они считаются свежими? Где фиксируется исключение? Кто сможет объяснить отказ через месяц?
Одна и та же policy function размещается по-разному в зависимости от масштаба.
Модульный монолит
AccessPolicy находится в доменном модуле Reports. Application service загружает
факты через репозитории и вызывает чистое решение. Это лучший старт, если нет
независимого масштабирования и десятков систем-потребителей.
Плюсы: одна транзакционная граница, простая трассировка, быстрые тесты. Риск: контроллеры и ORM-запросы начинают обходить policy API. Архитектурный тест может запретить зависимость Web → Persistence минуя Application.
Hexagonal architecture
Домен знает AccessFacts и AccessDecision, но не знает HTTP, Entity
Framework, Spring, Ecto или SQLAlchemy. Порты описывают получение фактов и
аудит, адаптеры реализуют конкретную инфраструктуру.
Inbound adapter → OpenReport use case → AccessPolicy
↓ ↑
FactProvider port pure domain
↓
DB / IAM / cache adapters
Такую границу одинаково можно реализовать в ASP.NET Core, Spring Boot, FastAPI или Phoenix. Hexagonal architecture — не структура папок, а направление зависимостей.
Отдельный policy service
Когда много продуктов используют общие правила, появляется Policy Decision Point. Сервисы остаются Policy Enforcement Point: они запрашивают решение и обязаны применить его.
Новые проблемы:
- сетевой отказ и выбор fail-open/fail-closed;
- latency budget и пакетные решения для списков;
- версия политики и обратная совместимость;
- защита самого запроса к policy service;
- утечка чувствительных атрибутов;
- согласованность кеша и отзыв разрешений.
API должен возвращать не только allow, но и decisionId, policyVersion,
безопасный reason code и срок действия решения. Полную трассу нельзя бездумно
показывать клиенту: она может раскрыть устройство защиты.
Event-driven проекции
Для быстрого чтения сервис поддерживает локальную проекцию ролей и grants из
событий RoleGranted, RoleRevoked, EmployeeBlocked. Это уменьшает сетевые
вызовы, но решение принимается по потенциально устаревшему снимку.
Нужны идемпотентные handlers, позиция потока, метрика lag, процедура rebuild и правило поведения при превышении допустимой давности. Слово «eventual» без численного SLO ничего не объясняет.
CQRS и списки отчётов
Query side должен сразу фильтровать доступные отчёты, иначе приложение сначала выгрузит секретные строки, а затем скроет их в памяти. Command side повторно проверяет конкретный ресурс: видимость в списке не является capability на последующее действие.
Frontend
React или любой другой frontend может скрывать кнопку для удобства, но не
является enforcement point. TypeScript-тип CanExport = true не защищает API.
Клиент использует capabilities от backend для интерфейса, а сервер повторяет
авторизацию на каждой операции.
Архитектура здесь продолжает историю логики: каждое новое распределение компонентов добавляет неявные посылки. Их нужно превращать в контракты, наблюдаемость и тестируемые свойства.
Исторический маршрут закончен. Осталось превратить его в рабочий протокол.
11. От требования до production: практический маршрут
Когда встречаете правило, вывод или рекомендацию, пройдите семь шагов:
- Назовите решение: что именно предлагается сделать?
- Уточните слова: какие понятия участники могут понимать по-разному?
- Выпишите основания: что известно, а что только предполагается?
- Восстановите скрытый мост между основаниями и выводом.
- Проверьте пограничные случаи и возможные альтернативные причины.
- Выберите минимальную формализацию: таблицу, схему или список условий.
- Вернитесь к реальности: кто исполнит решение и что покажет ошибку?
Логика не превращает жизнь в формулы. Она делает переходы видимыми: от слова к основанию, от основания к выводу, от вывода к правилу и от правила к последствиям.
Для Atlas Reports полный инженерный маршрут выглядит так.
1. Создать decision record
Зафиксировать угрозы, бизнес-правила, владельца политики, допустимую рассогласованность, fail-open/fail-closed и критерии аудита. Отдельно выписать нецели: например, UI-видимость кнопки не является безопасностью.
2. Построить ubiquitous language
Определить Principal, Tenant, Report, Grant, Deny, Scope,
AccessFacts, AccessDecision. Избегать универсальных UserType,
PermissionData и IsAdmin, которые скрывают разные отношения.
3. Составить decision table
Проверить приоритет deny, границы tenant, истечение grants, блокировку, повторную аутентификацию и недоступность источника фактов. Согласовать таблицу с безопасностью и продуктом до реализации.
4. Выделить чистую политику
Функция принимает неизменяемый снимок фактов и возвращает объяснимое решение. Она не читает сеть, БД, часы или глобальную конфигурацию. В ООП это может быть доменный service со спецификациями; в ФП — композиция чистых функций.
5. Обернуть эффекты application service
Загрузить факты, проверить их свежесть, вызвать policy, применить решение и записать аудит. Таймаут, retry и circuit breaker относятся к оболочке, а не к логике allow/deny.
6. Построить пирамиду доказательств
- example tests проверяют строки decision table;
- property-based tests проверяют инварианты на множестве входов;
- mutation tests показывают, замечают ли тесты изменение
&&на||; - contract tests проверяют форму данных IAM и policy service;
- integration tests проверяют tenant-фильтрацию и транзакции;
- end-to-end tests проверяют критические пользовательские маршруты;
- differential tests сравнивают версии движка на реальных обезличенных фактах.
7. Сделать решение наблюдаемым
Логировать decisionId, версию политики, категорию причины, latency и свежесть
фактов. Не логировать токены и лишние персональные данные. Метрики должны
показывать рост deny, ошибки зависимостей, cache lag и расхождения shadow-mode.
8. Выпускать политику как код
Версионировать, ревьюить, прогонять corpus решений, включать новую версию в shadow mode, сравнивать результаты и только потом переключать enforcement. Rollback политики должен быть таким же понятным, как rollback приложения.
| Язык | Сильный учебный ракурс | Осторожно |
|---|---|---|
| C# | value objects, закрытые конструкторы, Specification | не прятать I/O в доменных методах |
| Java | records, sealed types, Predicate |
boolean беден для объяснимого решения |
| Python | dataclasses, policy as data, property tests | валидировать динамические границы |
| Elixir | pattern matching, with, явные {:ok, _} / {:error, _} |
порядок clauses является семантикой |
| TypeScript | discriminated unions, readonly data, combinators | типы исчезают на HTTP-границе |
| JavaScript | простые функции, явный runtime parsing | JSDoc не заменяет проверку входа |
Главный результат не конкретный паттерн. Команда должна уметь ответить: почему решение принято, на каких фактах, какой закон сохраняется при рефакторинге и что произойдёт при отказе каждого внешнего компонента.
Самопроверка
Попробуйте без подсказки объяснить:
- почему убедительность и правильность вывода — разные качества;
- где в обычной фразе прячется энтимема;
- почему из следствия нельзя автоматически восстановить причину;
- что формализация делает видимым, но не решает за нас;
- чем правило отличается от процедуры;
- почему множества, преобразования и композиция отвечают на разные вопросы.
Если ответы складываются в одну историю, глава выполнила задачу.
Проверьте, можете ли вы спроектировать политику без кода:
- восстановить скрытые посылки из product requirement;
- назвать доменные типы и недопустимые состояния;
- составить decision table и указать приоритет правил;
- отделить чистое решение от I/O и времени;
- выбрать объектную, функциональную или гибридную модель осознанно;
- описать TOCTOU, cache staleness и fail-open/fail-closed;
- сформулировать свойства для property-based и differential tests;
- объяснить, где находится enforcement point в frontend, backend и распределённой системе.
Если один из пунктов остаётся туманным, вернитесь не к языку, а к тому историческому повороту, который сделал соответствующую неопределённость явной.
Источники и продолжение
- Аристотель. «Первая аналитика», «Риторика» и «О софистических опровержениях».
- Георгий Челпанов. «Учебник логики».
- George Boole. An Investigation of the Laws of Thought.
- Gottlob Frege. Begriffsschrift (1879).
- Bertrand Russell. The Principles of Mathematics.
- Kurt Gödel. Über formal unentscheidbare Sätze der Principia Mathematica und verwandter Systeme I.
- Alan Turing. On Computable Numbers, with an Application to the Entscheidungsproblem.
- Claude Shannon. A Symbolic Analysis of Relay and Switching Circuits.
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software.
- Martin Kleppmann. Designing Data-Intensive Applications.
- Scott Wlaschin. Domain Modeling Made Functional.
- Michael Feathers. Working Effectively with Legacy Code.