Политики и управление
Ревьюер ловит 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 — отмечает то, чего не должно быть
Заголовок раздела «forbid — отмечает то, чего не должно быть»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.track — Payments несёт security.zone: "PCI", Analytics несёт security.zone: "Public", а процесс доказывает ребро между ними.
forbid, ловящий только носителей аспекта, нуждается в паре — правиле покрытия. Когда обе стороны стрелки — атомы аспекта, совпадают только носители этого аспекта — беззонный промежуточный переход молча отмывает нарушение, поэтому в реальном управлении рядом с forbid про зону должна стоять проверка присутствия, forbid service and where (not @@security.zone) (или её эквивалент для другого ключа), чтобы изначально ничто не оставалось неклассифицированным.
require — обязательства, а не запреты
Заголовок раздела «require — обязательства, а не запреты»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_SNAKE — CROSS_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 красит пересечения зон на доске, — ревью безопасности открывает одну проекцию и видит ровно то, что не проходит, а не документ, который кому-то приходится вручную держать синхронизированным с моделью.
Гейты изменений — when
Заголовок раздела «Гейты изменений — when»Правила состояния (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 обеспечивает на уровне одной политики, применённая ко всему дереву областей: смягчение правила — это всегда чей-то явный, видимый акт, никогда не побочный эффект того, в каком месте дерева модулей вы оказались.
Политики стандартной библиотеки (arch.policy)
Заголовок раздела «Политики стандартной библиотеки (arch.policy)»Писать правила в духе OwnershipCoverage с нуля в каждом рабочем пространстве — это ровно тот вид повторения, ради устранения которого существует пакет. arch.policy поставляет распространённые правила, нацеленные на базовые виды (service, external_system, …), так что словарь никогда не расходится — вы продолжаете писать service, а импортированная политика уже знает, что это такое.
| Политика | Правило |
|---|---|
OwnershipCoverage | Каждый service разрешает @@team. |
SystemOwnership | Каждый system разрешает @@team. |
ExternalContract | Каждый external_system записывает @ext.vendor. |
DomainCoverage | Каждый service сидит в @@domain. |
ZoneIsolation | PCI-зонированные элементы никогда не вызывают Public-зонированные напрямую. |
ClassificationFlow | До элементов с классификацией pii дотягиваются только потребители, допущенные к pii (forbid плюс встроенный except для допущенных вызывающих). |
GatewayNoBypass | Межпространственные вызовы маршрутизируются через экспортированный шлюз — ни один селектор не сравнивает пространство-источник вызова с пространством цели, поэтому это правило едет на собственной проверке движка CROSS_SPACE_COUPLING и усиливает её через escalate. |
Импортируйте по одной политике на каждую инструкцию use policy — та же форма «инстанцировать правило управления в месте использования», что и при импорте типа, но для правил, а не для словаря:
use policy OwnershipCoverage from arch.policyuse 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.policyuse 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-workspacefile:///…/model.arch:12:9 ERROR PciEgressControl forbid Payments > Analytics.track1 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, в которых рендерится процесс, и как выбрать нужное под аудиторию.