How Humanity Learned to Reason: From Aristotle to Programs and Categories
A syllogism is a reasoning in which from accepted premises necessarily follows something different from them.
Aristotle, “First Analytics”, in a modern retelling
Imagine that a simple rule appears in the organization:
An employee can open a report if permitted.
On the first day everyone nods. On the second day questions begin. Who is considered an employee? Is the permission issued for one report or for all? What to do with temporary access? Can a manager grant access to themselves? What will happen if a person is fired, but the permission remains?
The history of logic begins precisely in such places. People formulate a clear rule, encounter an edge case, and are forced to name words, grounds, and the transition to a conclusion more precisely. Each subsequent theory in this chapter emerges not for complexity’s sake, but because the previous tool is no longer sufficient.
We will keep coming back to one question: what had to be made explicit in order for the solution to stop depending on a guess?
Suppose the Atlas team receives a requirement:
Authorized user can open the report.
This is not yet a specification. Identity, account state, role, user relationship to the resource, authentication freshness, tenant scope, permission duration, and system behavior when dependencies are unavailable are all mixed here.
If you write if right away, the implicit logic will be distributed among the controller, React component, SQL query, and middleware.
Chapter engineering edition will follow the same path:
неясное требование
→ аргумент и скрытые посылки
→ доменные понятия
→ предикаты и таблица решений
→ типы и инварианты
→ исполнимая политика
→ эффекты и отказы
→ архитектурная граница
We will not translate one if into six languages. C# and Java will show
the object model and specifications, TypeScript and JavaScript — algebraic types
and predicate composition, Python — declarative policy and properties, Elixir —
pattern matching, pipeline and explicit errors. These are different ways to think about
a rule, not a syntax showcase.
1. First, people learned not to prove but to persuade
At the meeting, the leader suggests: “Let’s give all managers access. They are responsible for the results and should see the reports.” The phrase sounds reasonable. It has a motive, a confident voice, and clear benefit. But responsibility for the results does not imply access to all data.
Between the cause and the solution are hidden assumptions: every manager needs the same information; reports do not contain excess; the risk of leakage is lower than the benefit; the word “manager” unambiguously defines a group of people. Until the assumptions are named, audience consent is easy to take for proof.
The ancient Greek tradition gradually divided three tasks. Rhetoric studies how speech persuades. Dialectic checks a position with questions and objections. Logic asks whether the conclusion is supported by the premises.
The first practical logic skill is very simple: stop between “I want to agree” and “this really follows”. Hearing “this option is bought more often, so it is better”, separate the product’s popularity from its suitability for you. The first may be true, but the second requires a new basis.
Architectural discussions also begin with rhetoric:
Users complain about speed, so let’s rewrite the service in Elixir.
Elixir can be a great choice, but the premise describes a symptom, and the conclusion — a specific and expensive solution. Between them are missing the delay profile, bottleneck boundary, load model, migration cost, and success criterion. Even the technically correct statement “BEAM works well with a large number of lightweight processes” does not prove that the delay arose due to the concurrency model.
Engineering argument is useful to break down as ADR:
Наблюдение: p95 открытия отчёта вырос с 280 мс до 1,8 с.
Причина: 74% времени занимает последовательный вызов трёх policy-сервисов.
Ограничение: нельзя ослаблять аудит и изоляцию арендаторов.
Варианты: параллельные вызовы, локальный снимок политики, смена протокола,
перенос вычисления или переписывание сервиса.
Решение: ...
Проверка: p95 < 500 мс при 2 000 RPS, решения совпадают с эталоном.
Thus logic enters the architecture before code. RFC, ADR and design review are needed not for bureaucracy, but to restore the bridge between fact and decision. A good document separates measurement from interpretation, constraint from preference and reversible decision from irreversible.
Engineering rule: technology is not a consequence of a problem until the mechanism through which it changes the observable outcome is named.
People have learned to distinguish persuasiveness and compliance. The next task is harder: how to see the same pattern in stories with different heroes?
2. Aristotle separates form from content
Let’s take the reasoning:
Все владельцы отчёта имеют право его открыть.
Мира — владелец отчёта.
Следовательно, Мира имеет право открыть отчёт.
Replace the content with letters:
Все M являются P.
S является M.
Следовательно, S является P.
Mira and the report are gone, but the framework remains. Aristotle made this framework the subject of study. Validity relates to form: with true premises, a correct inference cannot yield a false conclusion. It does not guarantee that the premises themselves are true.
In everyday speech, part of the reasoning is often omitted: “Mira has a service card, so the report can be opened.” We add the rule “the card owner has access” ourself. Such a shortened reasoning is called a enthymeme. Without enthymemes, conversation would be unbearably detailed, but it is precisely in the omitted premise that disagreement is usually hidden.
Therefore, during a dispute, it is useful not to repeat the conclusion louder, but to ask: “What general rule connects this fact with the decision?”
The requirement “the user can export a report” is an engineering enthymeme. It
lacks a general rule: which user, which report, in which tenant, under what
session state, and what does “can” mean — see a button, receive
200 OK or complete an asynchronous export?
OOP is useful here not because “everything is an object,” but because the model
can fix concepts and prohibit random substitutions. In C# we start not with
User.IsAdmin, but with different identities and explicit decisions:
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;
}
Individual types do not prove access rights, but they prevent mixing up
EmployeeId and ReportId. The hierarchy of resolution forces the calling code to resolve
not only true, but also the reason for refusal. This is a modern version of the Aristotelian
step: remove the plot and name the operation form.
Then we restore the hidden premises as domain assertions:
1. Employee активен в Tenant.
2. Report принадлежит тому же Tenant.
3. Employee является владельцем Report или имеет роль Auditor в его области.
4. Для чувствительного Report сессия должна быть повторно подтверждена.
5. Явный запрет сильнее разрешающей роли.
In Java the same technique is conveniently expressed record for values and sealed interface
for a finite set of decisions. The point is not the language: we turn conversational
nouns into types, and a hidden transition into a contract. After that the argument
“the admin can do anything” becomes a concrete question to the policy.
Antipattern: object User with dozens of flags and method
CanDoEverything() does not model the domain — it encapsulates an enthymeme inside
a universal container.
Syllogism works well with classes and properties. But access rules also consist of “if”, “and”, “or”, and “not”. A different perspective is needed for them.
3. Stoics move from things to conditions
Let us consider the derivation:
Если учётная запись заблокирована, отчёт открыть нельзя.
Учётная запись Миры заблокирована.
Следовательно, Мира не может открыть отчёт.
What matters here is not the class to which Mira belongs, but the connection between whole statements: “if P, then Q; P; therefore, Q”. Stoic logicians studied such forms long before computers.
This language immediately detects a common fallacy. If a lock implies a ban, then the ban itself does not imply the lock. Access could be denied due to an expired permission or an inappropriate report. We often confuse a sufficient cause with the only possible one.
In everyday life, it looks like this: “if it is raining, the road is wet.” Seeing a wet road, one cannot confidently conclude that it rained: a watering truck might have passed. The logical form helps avoid inventing a cause based on a single effect.
Propositional logic is the foundation of guard clauses, feature flags, firewall
rules, and routing conditions. But programmers must distinguish implication from
ordinary branching. The rule blocked → denied does not imply denied → blocked.
If an API returns only 403, the client cannot recover the cause.
It is useful to first make the solution explicit in JavaScript, and then shorten it:
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" };
}
This is longer than a boolean expression, but the order of rules and the priority of prohibition are visible.
If reasons are needed for auditing, boolean is already too poor a result type.
Now we will check the form with a decision table. For each factor, we select an equivalent class, rather than enumerating the entire world:
| Blocked | Same tenant | Owner or auditor | Decision |
|---|---|---|---|
| 1 | any | any | deny: account-blocked |
| 0 | 0 | any | deny: tenant-mismatch |
| 0 | 1 | 0 | deny: missing-grant |
| 0 | 1 | 1 | allow |
The table reveals the semantics of short-circuiting. This is already an architectural decision: if you first request a remote role service and then check local blocking, the system wastes the network and may leak through differences in response time.
De Morgan’s laws help review negations:
НЕ (owner ИЛИ auditor) = НЕ owner И НЕ auditor
НЕ (owner И auditor) = НЕ owner ИЛИ НЕ auditor
Fallacies here regularly survive code review, especially when a positive business phrase turns into several negative guard clauses.
Conditions were still recorded in words. The next step is to make them a computational object.
4. Leibniz Dreams of Computation, Boole Builds Algebra
Leibniz dreamed of a language in which disputes could be resolved by checking symbols: “Let us calculate.” He did not fully construct such a language, but the idea turned out to be fruitful. In the 19th century, George Boole described logical relations as algebra.
Our rule becomes an expression:
доступ = (владелец ИЛИ аудитор)
И повторная_проверка
И НЕ заблокирован
The formula does not decide whether the rule is fair. It forces equal treatment of equal cases. Just write down four or five lines of the table, and the questions hidden in the word “usually” become visible.
This can be used to verify a discount, insurance, or the right to return a product. Formalization is useful not because it replaces a person, but because it prevents conditions from being changed unnoticed during reasoning.
Boolean algebra provides a computable form, but the engineering task has only just begun.
In TypeScript, it is better to return an algebraic type of the solution rather than pass boolean
through the entire system:
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" };
}
Here the function is pure: identical facts give the same solution, the network and DB
are not hidden inside. Readonly does not make the program mathematically pure, but
it enforces the intention not to change the input during computation.
The pure core allows checking not only examples, but also properties:
// Псевдокод property-based теста
forAll(accessFacts, (facts) => {
if (facts.blocked) {
expect(decide(facts)).toEqual({
kind: "denied",
reason: "account-blocked"
});
}
});
Useful properties of the policy:
- blocking always dominates over allowing;
- changing another tenant cannot turn deny into allow;
- adding a role cannot bypass re-authentication;
- the result contains the rule version for reproducible audit.
This is already a functional design: data is immutable, the solution is expressed as a value, effects are pushed to the boundary. Later we will compare it with an object specification.
Boolean formulas can connect ready-made statements. But how to express relations between specific people, reports, and organizations?
5. Frege adds variables, relations and quantifiers
The phrase “User can open a report” refers to two objects and the relation between them. A simple “true or false” is not enough: there must be room for a specific user and a specific report.
Such predicates as может_открыть(человек, отчёт) and quantifiers appear:
“for each report”, “there exists an employee”. A quantifier helps to notice a dangerous
scale substitution. From the fact that each employee reads some report, it does not follow
that there exists one report that everyone reads.
This distinction occurs constantly in the research. “For each participant a useful practice was found” and “one practice turned out to be useful for all” — are different statements. Swapping two words changes the meaning of the result.
Check the generalization with three questions: what objects are being discussed, what is the relationship between them, and is the conclusion stated for all cases.
Fregean transition from statement to predicate is familiar to any developer:
canOpen(user, report) → Decision
In Python it is convenient to separate facts from policy and make dependencies explicit:
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
)
Now quantifiers are exposed in the collection APIs:
all(can_open(user, report) for report in reports) # ∀ report
any(can_open(user, report) for report in reports) # ∃ report
Replacement of all with any is not a minor loop error, but a change of logical
assertion. In SQL the same difference is hidden between NOT EXISTS and EXISTS, and in
ORM it may be lost behind a convenient method name.
The quantifier’s scope is even more important. The check “the user has the auditor role in some organization” is not the same as “the user is an auditor of the organization of this report”. The predicate must include the resource scope:
def is_auditor_for(user: User, report: Report) -> bool:
return (
user.tenant_id == report.tenant_id
and Role.AUDITOR in user.roles
)
At the architectural level, this is protection against confused deputy and cross-tenant access. A role without a scope is almost always an incomplete model. If the policy is translated into SQL, the tenant filter must be part of the query, not post-filtering after loading the data.
The more precise the language became, the more noticeable its own boundaries were.
6. Paradoxes and formal systems draw boundaries
The naive idea of a set sounds harmless: one can gather together all objects with the desired property. Russell asked what would happen to the set of all sets that do not contain themselves. If it contains itself, it should not; if it does not, it should.
The paradox showed: one cannot transform any description into an object without limitations. Rules for forming admissible sets are needed. Later, Hilbert’s program attempted to build a reliable foundation for mathematics, while Gödel’s results showed that a sufficiently expressive consistent formal system cannot prove within itself all true statements of its language.
Practical conclusions are humbler than loud slogans: any method works within its premises. A table won’t prove we’ve chosen fair criteria. Checking a questionnaire doesn’t guarantee truthful answers. Rigor starts with an honest description of the boundary.
Engineers often retell Gödel as “impossible to prove a program”, but this is too crude. For specific programs, properties are proven, types are used, model checking and proof assistants. The limitation is elsewhere: expressiveness, consistency, decidability and completeness are not given simultaneously for free.
In the applied model, the first line of defense is to make invalid states
unrepresentable. Java sealed interface restricts the principal space:
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 {}
The compiler may require a full switch over the cases. But it won’t prove
that tenantId came from a trusted source, that the role wasn’t revoked a second
ago or that the clocks of two services are synchronized. The typical model outlines one
boundary of proof, and doesn’t replace the security system.
In C# nullable reference types distinguish “value may be absent” from
accidental null, required won’t let you forget a field during initialization, and a private
constructor can preserve the invariant:
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);
}
}
But the time is passed as an argument not by chance. If the class calls
DateTimeOffset.UtcNow itself, the test and the property proof begin to depend on
an implicit effect.
The boundary of formalization should be written next to the model:
- types guarantee the data shape after successful validation;
- policy guarantees a solution for the provided facts snapshot;
- repositories are responsible for the origin and freshness of facts;
- integration tests check the components assembly;
- monitoring checks the behavior of an already running system.
The formula describes a relation. The computer needs a procedure that will receive data and perform steps.
7. Turing turns a rule into a procedure, Shannon — into a scheme
In the 20th century, logic encountered the question: what does it mean at all to “be computable”? Turing’s model described a simple machine performing precise steps on symbols. Shannon showed that Boolean relations can be implemented with electrical relays. Reasoning became not only a record, but also a process.
However, the rule and the procedure are not the same. “Return possible within 14 days” — is a rule. Who checks the date, what happens without a receipt, how a dispute is recorded and when the money is returned — this is the procedure. A good rule can lead to a bad outcome if the procedure is incomplete.
Execution adds time and failure. Data may change between the check and the action, an employee may make a mistake, and the required system may not respond. Therefore after the question “is the rule correct?” a second one is always needed: “what will happen in the real process?”
A pure function decide(facts) knows nothing about fetching facts. A real use
case reads a user, a report, roles, and session state, then writes an audit.
This is the boundary between functional core and imperative shell.
Elixir makes possible failures visible through values and 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
Operator with does not automatically “make the code functional”. Its value is in
explicit pipeline form: each step returns a success or error value, and the
else branch defines failure semantics. A hidden network call inside
Policy.decide/1 would once again mix computation and effect.
Here appears the TOCTOU problem: the role may be revoked after reading, but before opening the report. Possible architectural answers vary:
- accept eventual consistency and limit the snapshot lifetime;
- perform check and read within one transactional boundary;
- issue short-lived capability token for a specific resource;
- version the policy and check the version on the resource side;
- send revocation events and be able to close already opened sessions.
The Shannon perspective is useful for optimization: a complex policy is a scheme of logical gates. One can minimize repeated conditions, but one cannot lose diagnostic causes and the order of rules. The shortest Boolean expression is not always the best production code.
Engineering rule: first define the failure semantics, then choose between fail-open and fail-closed. For access to a sensitive report, an unavailable policy service usually means deny or retry, not silent allowance.
Now we have objects, conditions, types, and a procedure. We need to figure out how to organize the code without turning the logic into one giant method.
8. OOP and FP give different forms of one policy
One rule can be explained in two ways. The first gathers around the entity its state and permissible actions: “the report itself knows who the owner is.” The second considers the data separately and applies transformations to them: “obtain the facts and compute the solution.”
This is not a debate about the only correct style. Sometimes an object with a stable history and change rules is more important. Sometimes it’s a transparent chain of computations that can be easily checked separately. Complex systems often use both approaches at different boundaries.
A useful question is not “what is better overall?”, but “where should the rule reside, what data does it need, and how will we ensure that it hasn’t changed accidentally?”
OOP and FP model not different worlds, but different centers of gravity. OOP binds behavior to objects and protects invariants. FP makes transformations explicit, prefers values and function composition. Access policy well demonstrates the strengths and weaknesses of both approaches.
Object Specification in C#
The Specification Pattern is useful when rules need to be named, combined, and explained:
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;
}
It is not worth building an infinite tree AndSpecification<OrSpecification<...>>,
if the business values the order of failure causes. The composition should preserve not only
bool, but also explainability.
Predicates in Java
Java already contains the functional interface 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);
This is compact for filtering. For production-level authorization, a rich result is needed again: which predicate failed, which policy version was active, and which facts can be safely placed in the audit.
Composition in TypeScript
Functional combinators allow assembling 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;
};
Composition is good as long as the result type preserves the domain meaning. If everything
is reduced to A => boolean, the reasons and the possibility of contract evolution will be lost.
Dynamic boundary in JavaScript
JavaScript is convenient for simple predicates, but HTTP input cannot be considered typed just because the IDE knows 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 })
};
}
Validation at the boundary and an immutable internal value are more important than classes for the sake of classes. After the walkthrough, ordinary JavaScript is capable of supporting the same functional core.
Pattern matching on Elixir
In Elixir, rules are naturally expressed with multiple function clauses:
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"}
The order of statements is part of the policy. This is very readable for first-match semantics, but a new developer must understand that permuting lines changes the result.
Declarative rules in Python
Python allows collecting rules as data and obtaining a solution trace:
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"}
Declarativeness has a price: weaker static guarantees, it is easy to put an effect into a lambda and harder to navigate a too abstract DSL.
The final choice is usually hybrid: domain objects protect local invariants, a pure function takes a snapshot of facts, and the application service coordinates effects. This is not a compromise of “neither fish nor fowl”, but a separation of different types of complexity.
Set theory, group theory, and category theory help look at the model from different perspectives. They do not form a ladder from simple to “the smartest”.
9. Sets, Invariants, and Composition — Three Engineering Lenses
Sets ask who belongs to the considered group. For access this are employees, owners, auditors, and blocked accounts. A fallacy occurs when a conclusion about one group is silently transferred to another.
Transformations and invariants ask what can be changed without breaking an important property. One can rename roles or reorder process steps, but should access rights remain the same?
Composition looks at the connection of steps. Each stage individually may be reasonable, but the entire chain — erroneous. The questionnaire is assembled correctly, data calculated accurately, the graph built honestly, yet the conclusion still does not relate to the original question.
Choose the lens by the problem. They argue about who was counted — start with sets. They compare two processes — name the preserved property. The error arises at the junction — investigate the composition.
These three lenses are directly related to the architecture.
Sets: RBAC, ABAC and data scope
RBAC describes membership in role sets. ABAC computes a decision from the user’s, resource’s, and environment attributes. A real system often combines both approaches: a role provides a candidate permission, tenant and resource classification narrow the scope, and an explicit deny excludes the object.
allowedReports(user)
= ownedReports(user)
∪ auditedReports(user.tenant)
− explicitlyDeniedReports(user)
This description reminds that filtering a list and authorizing a single resource should use the same policy. Otherwise, the interface will show less or more data than the endpoint actually allows to open.
Invariants: what must be preserved
During refactoring of the policy engine, it is not the same classes that are important, but the observable properties:
- a cross-tenant query never resolves;
- revoking a role monotonically reduces the set of available reports;
- caching does not change the decision for longer than the allowed window;
- retries do not create audit duplicates;
- a new policy version can explain the difference from the old one.
These properties are suitable for property-based, differential, and mutation testing. You can run the old and new engine on a recorded set of anonymized facts and compare the decisions before switching the traffic.
Composition: contracts between stages
The architectural chain looks like this:
HTTP request
→ Authentication
→ Tenant resolution
→ Fact loading
→ Policy decision point
→ Policy enforcement point
→ Report storage
→ Audit log
Locally correct components do not guarantee a correct composition. If authentication issues a global role, tenant resolution selects a tenant from the URL, and the policy engine assumes the role is already restricted to a tenant, a vulnerability appears at the contract boundary.
Categorical intuition is practical here: the output of one transformation must
match the input of the next, composition must preserve laws, and the
identity transformation must not change the meaning. It is not necessary to write
terms Functor and Monad in business code to check composition.
Now let’s gather ideas not into a learning example, but into several real architectures.
10. How policy lives in different architectures
In a small organization, a rule may live in the instructions of one department. As growth occurs, multiple systems, branches, temporary staff, and audits appear. What was previously resolved by a familiar person turns into a separate process.
Centralization makes rules more uniform, but creates dependency: if the common center is unavailable, work stops. Local copies are faster, but can become outdated. There is no solution without a price — there is a compromise choice and an honest description of consequences.
Logic helps not to choose architecture automatically, but to retain the meaning of the rule during growth. Who makes the decision? On what data? How long are they considered fresh? Where is the exception recorded? Who will be able to explain the refusal in a month?
The same policy function is placed differently depending on the scale.
Modular monolith
AccessPolicy is located in the domain module Reports. Application service loads
facts through repositories and calls a clean solution. This is the best start if there is no
independent scaling and dozens of consumer systems.
Advantages: one transactional boundary, simple tracing, fast tests. Risk: controllers and ORM queries start bypassing the policy API. An architectural test can prohibit the dependency Web → Persistence bypassing Application.
Hexagonal architecture
The domain knows AccessFacts and AccessDecision, but does not know HTTP, Entity
Framework, Spring, Ecto or SQLAlchemy. Ports describe fact retrieval and
auditing, adapters implement specific infrastructure.
Inbound adapter → OpenReport use case → AccessPolicy
↓ ↑
FactProvider port pure domain
↓
DB / IAM / cache adapters
Such a boundary can be implemented equally in ASP.NET Core, Spring Boot, FastAPI or Phoenix. Hexagonal architecture is not a folder structure, but a direction of dependencies.
Standalone policy service
When many products use common rules, a Policy Decision Point appears. Services remain Policy Enforcement Points: they request a decision and must apply it.
New issues:
- network failure and choosing fail-open/fail-closed;
- latency budget and batch solutions for lists;
- policy version and backward compatibility;
- protecting the request to the policy service itself;
- leakage of sensitive attributes;
- cache consistency and permission revocation.
The API should return not only allow, but also decisionId, policyVersion,
a secure reason code and the decision’s validity period. The full trace cannot be carelessly
shown to the client: it might reveal the protection mechanism.
Event-driven projections
For fast reading, the service supports local projection of roles and grants from
events RoleGranted, RoleRevoked, EmployeeBlocked. This reduces network
calls, but the decision is made based on a potentially outdated snapshot.
Idempotent handlers are needed, stream position, lag metric, rebuild procedure, and behavior rule when exceeding the allowable staleness. The word “eventual” without a numerical SLO explains nothing.
CQRS and report lists
The query side should immediately filter available reports; otherwise, the application will first load secret rows and then hide them in memory. The command side re-checks a specific resource: visibility in the list is not a capability for subsequent actions.
Frontend
React or any other frontend may hide a button for convenience, but is not
an enforcement point. TypeScript type CanExport = true does not protect the API.
The client uses capabilities from the backend for the interface, and the server repeats
authorization on each operation.
The architecture here continues the logic history: each new component distribution adds implicit assumptions. They need to be turned into contracts, observability, and testable properties.
Historical track is complete. It remains to turn it into a working protocol.
11. From requirement to production: practical track
When you encounter a rule, derivation or recommendation, go through seven stages:
- Name the solution: what exactly is proposed to do?
- Clarify the words: which concepts might participants understand differently?
- List the premises: what is known, and what is only assumed?
- Restore the hidden bridge between premises and conclusion.
- Check edge cases and possible alternative causes.
- Choose the minimal formalization: a table, a diagram, or a list of conditions.
- Return to reality: who will implement the solution and what will indicate an error?
Logic does not turn life into formulas. It makes transitions visible: from a word to a foundation, from a foundation to a conclusion, from a conclusion to a rule and from a rule to consequences.
For Atlas Reports the full engineering track looks like this.
1. Create decision record
Document the threats, business rules, policy owner, acceptable discrepancy, fail-open/fail-closed, and audit criteria. List separately non-goals: for example, UI visibility of a button is not security.
2. Build a ubiquitous language
Define Principal, Tenant, Report, Grant, Deny, Scope,
AccessFacts, AccessDecision. Avoid universal UserType,
PermissionData and IsAdmin, which hide different relations.
3. Compose decision table
Check deny priority, tenant boundaries, grant expiration, locking, re-authentication, and fact source unavailability. Align the table with security and the product before implementation.
4. Extract pure policy
The function takes an immutable snapshot of facts and returns an explainable decision. It does not read the network, DB, clock, or global configuration. In OOP this could be a domain service with specifications; in FP — a composition of pure functions.
5. Wrap application service effects
Load facts, check their freshness, invoke policy, apply decision and record audit. Timeout, retry and circuit breaker belong to the shell, not to the allow/deny logic.
6. Build a proof pyramid
- example tests check decision table strings;
- property-based tests check invariants across input sets;
- mutation tests show whether tests detect the change of
&&to||; - contract tests check the data shape of IAM and policy service;
- integration tests check tenant filtering and transactions;
- end-to-end tests check critical user paths;
- differential tests compare engine versions using real anonymized facts.
7. Make the solution observable
Log decisionId, policy version, reason category, latency and freshness
of facts. Do not log tokens and extra personal data. Metrics should
show growth of deny, dependency errors, cache lag and shadow-mode
divergences.
8. Release policy as code
Version, review, run the solution corpus, include the new version in shadow mode, compare the results, and only then switch enforcement. Rollback of policies should be as clear as application rollback.
| Language | Strong learning perspective | Caution |
|---|---|---|
| C# | value objects, closed constructors, Specification | do not hide I/O in domain methods |
| Java | records, sealed types, Predicate |
boolean is poor for an explainable solution |
| Python | dataclasses, policy as data, property tests | validate dynamic boundaries |
| Elixir | pattern matching, with, explicit {:ok, _} / {:error, _} |
clause order is semantics |
| TypeScript | discriminated unions, readonly data, combinators | types vanish at HTTP boundary |
| JavaScript | simple functions, explicit runtime parsing | JSDoc does not replace input validation |
The main outcome is not a specific pattern. The team should be able to answer: why the decision was made, based on what facts, which law is preserved during refactoring and what will happen if each external component fails.
Self-check
Try to explain without a hint:
- why persuasiveness and correctness of inference are different qualities;
- where an enthymeme hides in a common phrase;
- why from a consequence one cannot automatically restore the cause;
- what formalization makes visible but does not solve for us;
- what distinguishes a rule from a procedure;
- why sets, transformations, and composition answer different questions.
If the answers form a single story, the chapter has fulfilled its task.
Check if you can design a policy without code:
- recover hidden assumptions from product requirement;
- name domain types and invalid states;
- create a decision table and specify rule priorities;
- separate the pure solution from I/O and time;
- choose an object-oriented, functional, or hybrid model consciously;
- describe TOCTOU, cache staleness, and fail-open/fail-closed;
- formulate properties for property-based and differential tests;
- explain where the enforcement point is located in frontend, backend, and distributed system.
If one of the items remains vague, go back not to the language, but to that historical turning point which made the corresponding ambiguity explicit.
Sources and continuation
- Aristotle. «First Analytics», «Rhetoric» and «On Sophistical Refutations».
- Georgy Chepanov. «Textbook of Logic».
- 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.