Перейти к содержимому

Политики и управление

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

Политика — это правило, которое живёт рядом с моделью, а не в голове ревьюера. Это запрос над архитектурой, который выдаёт находки — узел или ребро, нарушающие правило, — и эти находки роняют сборку: archlang check завершается ненулевым кодом, редактор показывает находку как диагностику, а проекция может нарисовать её на доске. Это архревью-как-код (architecture review as code): правило кусается потому, что так говорит модель, а не потому, что кто-то не забыл посмотреть.

policy TeamOwnership {
"Every service names an owning team."
severity: error
forbid service and where (not @@team)
}

Читается сверху вниз: описание, серьёзность, одно правило. Любой service без аспекта @@team — находка. Добавьте эту политику в рабочее пространство с сервисом без владельца, и archlang check упадёт — не потому, что заметил ревьюер, а потому, что сработало правило.

Политики построены на той же алгебре селекторов, что и проекции: forbid/require принимают те же селекторы, что и show/hide, @@key читает ту же ось аспекта, where (…) вычисляет те же выражения. Если вы умеете написать проекцию, которая что-то показывает, вы умеете написать и политику, которая это запрещает.

Тело политики — это зона заголовка — необязательная строка-описание, затем поля вроде severity: error — за которой следуют правила, читаемые сверху вниз. Это та же форма зоны заголовка, что и у тела проекции (Глава 8): зона заканчивается на первом правиле, и всё после него вычисляется по порядку.

policy #p001 PciIsolation {
"PCI workloads never talk to the public zone directly"
severity: error
forbid @@security.zone:"PCI" > @@security.zone:"Public"
}

Форматтер чеканит стабильный идентификатор #p001 при сохранении — тот же механизм, что штампует модули и процессы (Глава 13) — переименованная политика распознаётся как переименование, а не как удаление-плюс-добавление.

Политика объявляется на верхнем уровне файла (где угодно в поддереве пространства) или внутри тела модуля; in <Module> policy … { } — это размещённый двойник формы тела. Обе формы важны, и разница между ними — в области действия, о чём ниже.

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

service Payments {
aspect team: "Payments"
aspect { domain: "Payments"; security.zone: "PCI" }
rest_create authorize
}
service Orders {
aspect team: "Commerce"
aspect { domain: "Commerce" }
rest_create createOrder
}
gateway WebGateway {
aspect team: "Platform"
aspect { security.zone: "Public" }
rest_create forward
}
external_system Analytics {
aspect team: "Payments"
aspect { security.zone: "Public" }
ext.vendor: "Segment"
rest_create track
}
service AuditLog {
aspect team: "Platform"
rest_create record
}
process Checkout {
Customer > WebGateway.forward
WebGateway > Orders.createOrder
Orders > Payments.authorize
Payments > Analytics.track
Orders > AuditLog.record "compliance trail"
Payments > AuditLog.record "compliance trail"
}

Payments.authorize обращается к Analytics.track ради отслеживания конверсий — PCI-сервис напрямую достаёт до вендора в публичной сети. Это настоящая зависимость, которую доказывает процесс, и именно такую форму должна ловить политика.

forbid <selector> выдаёт одну находку на каждый совпавший элемент. Сорт селектора решает, что отмечается: forbid узлового сорта отмечает узлы, forbid рёберного сорта отмечает рёбра — закреплённые за исходной конечной точкой ребра.

// узловой сорт: каждый сервис без владельца — это находка
policy TeamOwnership {
severity: error
forbid service and where (not @@team)
}
// рёберный сорт: каждое ребро PCI→Public — это находка
policy PciEgressControl {
"PCI-zoned workloads never call a public-network module directly."
forbid @@security.zone:"PCI" > @@security.zone:"Public"
}

PciEgressControl срабатывает на Payments > Analytics.trackPayments несёт security.zone: "PCI", Analytics несёт security.zone: "Public", а процесс доказывает ребро между ними.

forbid, ловящий только носителей аспекта, нуждается в паре — правиле покрытия. Когда обе стороны стрелки — атомы аспекта, совпадают только носители этого аспекта — беззонный промежуточный переход молча отмывает нарушение, поэтому в реальном управлении рядом с forbid про зону должна стоять проверка присутствия, forbid service and where (not @@security.zone) (или её эквивалент для другого ключа), чтобы изначально ничто не оставалось неклассифицированным.

require <subject>: <obligation> — это другое направление: вместо того чтобы отмечать то, чего не должно быть, оно отмечает то, чего не хватает. Оно вычисляется по-субъектно, с this, привязанным к каждому элементу, с которым совпал селектор субъекта; находка срабатывает, когда селектор обязательства пуст.

policy AuditCoverage {
"Every service (except the audit sink itself) reports to the audit trail."
require service and not AuditLog: this >> AuditLog
}

Для каждого service, кроме самого AuditLog, обязательство — «где-то ниже по потоку достигает AuditLog» — this >> AuditLog, селектор ориентированного конуса из Главы 31. И Payments, и Orders вызывают AuditLog.record, напрямую или транзитивно, так что оба удовлетворяют правилу; сервис, добавленный позже без аудиторского следа, сразу же его не пройдёт.

Обязательство обязано упоминать this. require service: this > Audit осмысленно; require service: * > Audit разворачивается в «какой-то сервис где-то вызывает Audit» — истинно или ложно одинаково для каждого субъекта, что является адресной диагностикой (ловушка «обязательство не ограничивает this»). Простая булева проверка самого субъекта — это селектор this and where (<expr>):

policy ExternalContract {
"Every external dependency records its vendor."
require external_system: this and where (@ext.vendor)
}

except — исключения видимы, у них есть владелец и срок

Заголовок раздела «except — исключения видимы, у них есть владелец и срок»

У настоящих систем есть санкционированные исключения. PciEgressControl в том виде, в каком она написана выше, честна, но неполна — вызов аналитики это настоящее, проревьюенное решение, а не недосмотр. except фиксирует это решение, вместо того чтобы его прятать:

policy PciEgressControl {
"PCI-zoned workloads never call a public-network module directly."
forbid @@security.zone:"PCI" > @@security.zone:"Public"
except Payments > Analytics.track "Segment tracking is reviewed and approved for now" {
owner: "PlatformSec"
until: "2026-12-01"
}
}

Строка причины обязательнаexcept без причины это ошибка валидации, а не мягкое предупреждение. Она должна начинаться на собственной логической строке исключения. Необязательное тело { } по соглашению несёт метаданные управления (owner:, until:, ссылку на тикет) — обычные поля, которые хост может вывести в отчёте по исключениям.

Исключённая находка не удаляется — это отдельное, счётное состояние: долг по исключениям, о котором сообщают так же, как о долге по TODO. except, который ничего не исключает, — это линт («мёртвое исключение — удалите»), в том числе если его цель перестала нарушать правило. Исключения самоочищаются по мере того, как нарушения исправляются.

Новое или изменённое исключение не может авторизовать само себя. Рендеринг и редактор применяют исключение в тот же момент, когда оно написано, — именно это делает его полезным изо дня в день. Но на гейте (в предложении, в прогоне CI против базовой ссылки) исключение подавляет находку только если оно уже было в базе, с которой вы сравниваете, или если оно было введено или изменено в этом диффе и проревьюено как его часть. Никто не может добавить except и нарушение в одном и том же изменении и заставить исключение молча покрыть собственное нарушение — механику см. в разделе Гейты изменений ниже.

Размещение следует монотонности. Исключение живёт там, где политике принадлежит владение, — дочерняя область не может исключить политику родителя; см. Область действия и монотонность.

escalate — повышает ставки, никогда не понижает

Заголовок раздела «escalate — повышает ставки, никогда не понижает»

escalate <Policy|DIAGNOSTIC> to <severity> повышает серьёзность именованной политики или встроенной диагностики в текущей области действия. Это работает только на повышениеescalate, который бы понизил серьёзность, отклоняется (или просто не действует, для формы тела экземпляра use policy ниже).

policy PlatformGovernance {
"The platform team hardens the org baseline to build failures."
escalate CROSS_SPACE_COUPLING to error
}

Это нацелено на встроенную диагностику по её опубликованному ключу в SCREAMING_SNAKECROSS_SPACE_COUPLING это собственная проверка движка на вызов, пересекающий границу пространства без прохода через экспортированную цель, обычно предупреждение. Повышение до error превращает «вы, вероятно, хотели провести это через шлюз» в провал сборки. Повышение диагностического ключа, который политика иначе не может выразить как селектор, — это ровно то, как работает GatewayNoBypass из arch.policy (ниже).

escalate также принимает имя политикиescalate <Policy> to error повышает политику, поставляемую с warning или advisory (мягкий совет про владение уровня advisory в оргбейзлайне становится жёстким гейтом в области платформы). Он всегда только повышает: escalate уже-error-политики — это no-op, а escalate, который бы понизил серьёзность, отклоняется.

У каждой политики есть severity: error | warning | advisory. Значение по умолчанию — error: правило кусается по умолчанию, а смягчение — это явный акт.

СерьёзностьЭффект
errorПроваливает гейты — archlang check, archlang policy-check, гейт приёма Studio.
warningРендерится и попадает в отчёты; не проваливает гейт (если только гейт не запущен с --strict).
advisoryТолько поверхность отчётов/дашбордов — выбирается через violating, показывается на дашбордах, но никогда не попадает в поток диагностики LSP/CLI.

TODO остаётся совершенно отдельной осью — черновой долг, отслеживаемый механизмом нулевого TODO (archlang validate --complete), никогда не политикой. arch.policy намеренно оставляет ворота слияния с нулём TODO за пределами слоя политик именно по этой причине (подробнее в разделе Политики стандартной библиотеки ниже).

violating <Policy> — управление руководит визуалом

Заголовок раздела «violating <Policy> — управление руководит визуалом»

Активные (неисключённые) находки политики можно выбрать везде, где допустим селектор: violating <PolicyName>. Он несёт собственный сорт политики — violating у политики с рёберным forbid рёберного сорта, у политики с узловым require — узлового сорта — и естественно читается в style проекции:

view GovernanceBoard {
"Every active PCI-egress finding, painted red."
show service or gateway or external_system
style violating PciEgressControl { color: crimson }
}

Это тот же атом violating, которым Глава 28 красит пересечения зон на доске, — ревью безопасности открывает одну проекцию и видит ровно то, что не проходит, а не документ, который кому-то приходится вручную держать синхронизированным с моделью.

Правила состояния (forbid/require) отвечают на вопрос «корректна ли модель прямо сейчас». Гейты изменений отвечают на другой вопрос: «нужно ли этому изменению согласование человека». Клауза when — это обычный член тела политики, вычисляемый над диффом база→голова — предложением, диапазоном коммитов — вместо одного снимка.

policy ChangeReview {
"New PCI egress and zone membership changes route to security review."
when service added { require review from @@team }
when @@security.zone:"PCI" > * added { require review from 2 of @@team }
}
  • Субъекты-узлы принимают added, removed или changed. Голая форма значит, что собственное объявление элемента различается база→голова; форма-портал changed(<getter>) сужает до изменения разрешённого значения — «изменился ли @@security.zone», где бы правка ни была объявлена, — ловя каскадную правку, которая рябью меняет членство в зоне от родителя.
  • Субъекты-рёбра принимают только added/removed — у производного ребра нет собственного объявления, поэтому changed на нём — адресная диагностика. when @@security.zone:"PCI" > * added — это флагманский гейт «новый маршрут выхода из PCI».

Тело гейта — это require review from [N of] <key> — геттер (from @@team, разрешаемый по-субъектно, на стороне базы: изменение не может отредактировать своих собственных ревьюеров) или буквальный ключ (from SecurityReview). Необязательное число задаёт кворум: 2 of @@team требует согласования от двух различных значений команды.

Прогоните гейт изменений вхолостую против настоящей базовой ссылки прежде, чем он начнёт гейтить настоящие предложения:

$ archlang check my-workspace --against=HEAD
… validate, policy-check, format --check, evidence sections …
─── change gates (vs HEAD) ───
gate ChangeReview — added Refunds
requires review from Payments
✗ 1 change gate would require review vs HEAD (dry-run — the CLI has no approval state)

Добавление нового сервиса Refunds (aspect team: "Payments") к модели этой главы срабатывает ровно так: гейт when service added разрешает @@team на новом субъекте и сообщает «requires review from Payments». Гейт, который нельзя вот так отрепетировать, — это гейт, который люди молча отключают, — archlang check --against=<ref> делает его настоящим прежде, чем он станет нагрузкой.

Политика объявляется на верхнем уровне файла — по умолчанию, для всего пространства — или внутри тела модуля (in <Module> policy … { } для размещённой формы). Субъекты политики, размещённой в модуле, — это селектор, пересечённый с поддеревом этого модуля: узловые находки — по членству в поддереве, рёберные находки — по их исходной конечной точке.

in Payments policy PciEgressReview {
"Payments-local hardening: catch any Public-zone call before it leaves the module."
forbid @@security.zone:"PCI" > @@security.zone:"Public"
}

Размещённая внутри Payments, эта политика вычисляет то же правило, но только над собственным поддеревом Payments — команда, владеющая одним модулем, может добавить управление строже оргбейзлайна, ограниченное тем, чем она владеет.

Управление монотонно вниз по дереву. Дочерняя область наследует каждую политику сверху и может добавлять новые политики или escalate унаследованную — но никогда не может ослабить, удалить или исключить политику родителя. Исключение живёт там, где политике принадлежит владение; подкоманда не может молча исключить себя из-под правила, написанного платформенной командой. Это та же позиция, которую правило «только повышение» у escalate обеспечивает на уровне одной политики, применённая ко всему дереву областей: смягчение правила — это всегда чей-то явный, видимый акт, никогда не побочный эффект того, в каком месте дерева модулей вы оказались.

Писать правила в духе OwnershipCoverage с нуля в каждом рабочем пространстве — это ровно тот вид повторения, ради устранения которого существует пакет. arch.policy поставляет распространённые правила, нацеленные на базовые виды (service, external_system, …), так что словарь никогда не расходится — вы продолжаете писать service, а импортированная политика уже знает, что это такое.

ПолитикаПравило
OwnershipCoverageКаждый service разрешает @@team.
SystemOwnershipКаждый system разрешает @@team.
ExternalContractКаждый external_system записывает @ext.vendor.
DomainCoverageКаждый service сидит в @@domain.
ZoneIsolationPCI-зонированные элементы никогда не вызывают Public-зонированные напрямую.
ClassificationFlowДо элементов с классификацией pii дотягиваются только потребители, допущенные к pii (forbid плюс встроенный except для допущенных вызывающих).
GatewayNoBypassМежпространственные вызовы маршрутизируются через экспортированный шлюз — ни один селектор не сравнивает пространство-источник вызова с пространством цели, поэтому это правило едет на собственной проверке движка CROSS_SPACE_COUPLING и усиливает её через escalate.

Импортируйте по одной политике на каждую инструкцию use policy — та же форма «инстанцировать правило управления в месте использования», что и при импорте типа, но для правил, а не для словаря:

use policy OwnershipCoverage from arch.policy
use policy ExternalContract from arch.policy
use policy ZoneIsolation from arch.policy {
except Payments > Analytics.track "Segment tracking is reviewed and approved for now"
}
use policy GatewayNoBypass from arch.policy

use policy <Name> from <pkg> инстанцирует опубликованную политику так, будто она объявлена в вашей собственной области: она вычисляется над вашей моделью, подчиняется той же локальной монотонности, что и ваши собственные политики, и экземпляром владеете вы. Необязательное тело { } — единственное место, где живут ваши собственные исключения и повышения для этого импорта, — тело политики в исходном пакете остаётся доступным только для чтения извне. Её ссылки query по-прежнему разрешаются в исходном пакете (словарь путешествует), но всегда вычисляются против ваших данных — то, что проектный документ SELECTORS.md называет «словарь путешествует, данные остаются локальными». Принятие политики пакета — это всегда такое явное заявление; поднятие версии зависимости никогда не может молча начать проваливать ваш гейт.

Импортированная политика обеспечивается ровно так же, как объявленная: archlang check / archlang policy-check и диагностика редактора вычисляют импорты use policy … from наравне с политиками, объявленными в вашем рабочем пространстве, так что правило из пакета проваливает сборку в тот же момент, когда срабатывает его находка. use policy, который не может разрешить свой исходный пакет или политику, сам по себе является провалом гейта — импорт управления, который никогда не кусается, — это дыра, а не молчаливый пропуск.

Две политики arch.policy поставляет только как комментарии, намеренно не реализуя их как правила: ворота слияния с нулём TODO (ZeroTodo) и диффовую проверку «никаких новых входящих рёбер в сворачиваемый модуль» (StatusHygiene). Обе по-настоящему не подходят по форме под forbid/require — TODO по замыслу это своя собственная диагностическая ось (см. выше), а проверка сворачивания — это гейт изменений when … added, а не forbid состояния, и это меняет её смысл настолько, что втиснуть её в forbid значило бы молча её переинтерпретировать, а не выразить. Стоит прочитать, если вас тянет написать что-то похожее, — комментарии в policies.arch объясняют ровно то, в какой шов грамматики попадает каждая из них.

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

Три поверхности читают одни и те же объявления политик:

  • archlang check — одноразовый CI-гейт: validate + policy-check + format --check, плюс (с --against=<ref>) холостой прогон гейтов изменений. Код выхода — максимум по всем фазам; активная находка error роняет сборку.
  • archlang policy-check — вычисляет только политики, сам по себе: 0 при чистом результате, 1 при активной находке-ошибке, 2 при активном предупреждении под --strict. Находки advisory никогда не проваливают гейт.
  • Редактор (LSP) — выводит находки как обычные диагностики, умно-тихо: они появляются, только когда модель разрешается связно, а не громоздятся поверх не относящихся к делу ошибок разбора в наполовину черновом файле. Каждая диагностика несёт переход relatedInformation назад к правилу, которое её вызвало.

Прогоните проверку вхолостую локально прежде, чем сломается чья-то чужая сборка:

$ archlang policy-check my-workspace
file:///…/model.arch:12:9 ERROR PciEgressControl forbid Payments > Analytics.track
1 error, 0 warnings, 0 advisory

Добавьте исключение из примера except выше — и тот же прогон вернётся чистым: находка не исчезла, она переместилась из «активной» в «исключённую» и осталась ровно настолько же видимой в отчёте по исключениям.

  • policy — это запрос, который выдаёт находки — узлы или рёбра, нарушающие правило, — выводимые как диагностика, гейты и атом violating.
  • forbid <selector> отмечает совпадения (узлового или рёберного сорта, по селектору); require <subject>: <obligation> отмечает субъектов, у которых обязательство пусто, всегда через this.
  • except <selector> "<reason>" исключает — видимо и с обязательной причиной; новое или изменённое исключение не может авторизовать само себя на гейте.
  • escalate <Policy|DIAGNOSTIC> to <severity> повышает — никогда не понижает — серьёзность политики или встроенной диагностики.
  • severity: error | warning | advisory — единая ось: error проваливает гейты, warning только сообщает, advisory — только для дашборда.
  • Гейты изменений (when <selector> added|removed|changed(<getter>) { require review from [N of] <key> }) вычисляются над диффом база→голова и допускают холостой прогон через archlang check --against=<ref>.
  • Область действия — файловая или размещённая в модуле (in <Module> policy); управление монотонно вниз по дереву — дочерняя область добавляет и повышает, никогда не ослабляет и не исключает правило родителя.
  • arch.policy поставляет распространённые правила покрытия/изоляции/шлюзов, импортируемые по одному через инструкцию use policy … from ….
  • archlang check / archlang policy-check обеспечивают исполнение в CI; редактор выводит те же находки вживую.

Глава 33: Представления процессов → — представления flow, sequence и BPMN, в которых рендерится процесс, и как выбрать нужное под аудиторию.