← Все решения

Effects — Fail, Io, Db, handlers, with-блоки

Решения этой группы определяют центральную абстракцию Nova: алгебраические эффекты. Любое взаимодействие с внешним миром — эффект; у эффекта есть handler; handler перехватывается в with-скоупе. Из этой идеи следуют замены ключевых слов async/throws/unsafe на типы и единый механизм для тестов, транзакций, undo/redo, capability-режима.

#Решение
D2Эффекты вместо ключевых слов async/throws/unsafe
D3Синтаксис эффектов: типы между ) и ->
D4? для пробрасывания ошибки
D11Имена эффектов и синтаксис with
D12Effect erasure и dynamic effects
D18Эффекты объявляются через protocol, не type
D25throw и параметризация Fail[E]
D28Вывод эффектов: private — выводится, public — явно
D31Handler-лямбда для эффектов с одной операцией
D61Полная семантика эффектов: effect keyword, handler-литерал, Effect[E], interrupt
D62Прагматичная семантика эффектов: прямые в сигнатуре, Fail strict, Async ambient, правило effect/protocol
D63forbid X { body } — capability sandbox
D64realtime { body } — гарантия не-приостановки
D65Полная семантика Fail: гибрид Fail[E] / Fail, lookup, prelude RuntimeError и Error
D67⚠️ ОТМЕНЕНО → D85: ? оператор (две семантики)
D68Stateful handlers: через closure capture или @as_handler метод record
D85Операторы ? и !! — унифицированное поведение для Result и Option, throw-стиль через !!
D86?? coalesce-оператор — fallback для Result/Option без Fail
D87Effect[E, IRT] — параметризация Handler типом interrupt’а
D120#pure views + axioms + #verify/#trusted handlers
D115Axiom binder — BinderType enum вместо Option<TypeRef>

Полное введение в концепцию — ../effects.md. AI-first обоснование — 01-philosophy.md → D10.


D2. Эффекты вместо ключевых слов async/throws/unsafe

⚠️ REVISED → D62. Async / Mut / Par убраны из стандартного набора. Async стал ambient (невидимая инфраструктура fiber-runtime’а, см. D14), Par тоже не эффект — параллелизм через spawn/parallel без эффект-метки (D50). Mut удалён целиком (изменяемое состояние через mut-поля и mut-параметры, не эффект). Для no-suspend гарантии используется realtime { } block (D64) как inverse-маркер.

Что

Единая система эффектов заменяет два разнородных языковых механизма (throws, unsafe). Эффекты — обычные типы в PascalCase (Fail[E], Io, Db, Net, Log, Alloc[R]), выводятся компилятором в private, объявляются явно в public между списком параметров и ->.

Правило

Стандартный набор эффектов в stdlib (после D62):

ЭффектЧто описывает
Fail[E]Контракт для перехвата и обработки ошибки типа E (D25/D65)
IoФайлы, stdout/stderr
NetСетевые запросы
DbБаза данных
FsЧтение/запись файлов
TimeЧасы, таймеры, задержки
RandomRNG
Alloc[R]Аллокация в регионе R
LogСтруктурированный лог
TraceРаспределённая трассировка
Ask[T]Чтение из контекста (как Reader)

Все имена в PascalCase — это типы, не keyword’ы. Никаких специальных правил «эффекты с маленькой».

fn parse(s str) Fail -> int
fn fetch(url str) Net Fail -> Response
fn save(u User) Fail Db Log -> ()

Fail без параметров ≡ Fail[any] (D65) — catch-all для quick-and-dirty. Для production рекомендуется явный Fail[E].

Программист может объявлять собственные эффекты через keyword effect (D18 (REVISED), D61):

type Logger effect {
    log(msg str) -> ()
}

fn process(o Order) Logger Db -> Receipt {
    Logger.log("processing")
    Db.query(sql`SELECT receipt FROM orders WHERE id = ${o.id}`)
}

Почему

  1. Невидимое поведение в Java/Python/JS. Любая функция может бросить что угодно — это не видно по сигнатуре. Checked exceptions Java получились плохо: не комбинируются с дженериками и лямбдами. Go-стиль if err != nil — много шума, легко забыть.
  2. Async-вирус. В Rust/JS/C# async отравляет всю цепочку вызовов через Future<T> и обязательный await. В Nova suspension — ambient runtime-инфраструктура (D62, D14), без цвета функции и без await.
  3. AI-first. LLM, читая сигнатуру, знает все побочные действия. В Python/Java/Go этой информации в типе нет — для AI это восстанавливается чтением десятка вызываемых функций.
  4. Один механизм для всего. Тестирование без моков, транзакции, undo/redo, детерминированный запуск, трассировка, capability security — всё это handler’ы одного и того же механизма.

Что отвергнуто

  • async/throws/unsafe как отдельные keyword’ы. Три разных механизма для трёх случаев — каждый с собственными правилами композиции, перехвата и пропагации.
  • Lowercase имена эффектов (throws io async, как было в первых черновиках). Эффекты — типы, к ним применяется единое PascalCase-правило (03-syntax.md → D30).
  • Effects как ещё одна фича рядом с trait’ами. В Nova это центр языка (01-philosophy.md → D10).

Связь

  • D3 — позиция между ) и ->.
  • D11 — three positions имени эффекта, синтаксис with.
  • D18 — эффект объявляется через protocol, не type и не специальный keyword.
  • D25Fail[E] параметризация.
  • D28 — правило вывода private vs public.
  • 01-philosophy.md → D10 — «всё эффект» как центральная абстракция языка.
  • 06-concurrency.md → D14 — fiber runtime как ambient инфраструктура (suspension не в типах).

Эволюция

В первых черновиках имена эффектов были lowercase (throws io async) — их пытались выделить визуально из имён типов. Пересмотрено: эффекты — обычные типы, к ним применяется PascalCase-правило (D11, 03-syntax.md → D30). Fail без параметра теперь читается как сахар над Fail[Error]. Подробно — history/evolution.md.


D3. Синтаксис эффектов: типы между ) и ->

Что

Эффекты в сигнатуре функции перечисляются через пробел между закрывающей скобкой параметров ) и стрелкой возврата ->. Граница задана структурой, парсер однозначен, никаких маркеров и ограничителей.

Правило

fn save(u User) Fail Io -> ()
fn fetch(url str) Net Fail -> Response
fn process(o Order) Db Log -> Receipt
fn double(x int) -> int                          // нет эффектов — чистая

Параметры — без двоеточия (u User, не u: User) — единое правило для всех типов в Nova (02-types.md → D17, 03-syntax.md → D33).

Эффекты с параметрами читаются так же:

fn parse(s str) Fail[ParseError] -> int
fn alloc_in(buf []u8) Alloc[r] -> Buffer
fn read_ctx(key str) Ask[Config] -> str

Если эффектов нет — между ) и -> пусто:

fn add(a int, b int) -> int =>
    a + b

Эффекты в сигнатуре методов через @ — после параметров, перед ->:

fn Account mut @deposit(amount money) Fail Log -> () => ...

Почему

  1. Граница задана структурой. ) слева, -> справа — парсер однозначен без маркеров.
  2. Эффекты — это типы (D2, D11). Применяется единое PascalCase-правило (03-syntax.md → D30).
  3. Читается слева направо как фраза: «функция save от User бросает, делает Io, асинхронна, возвращает ()».

Что отвергнуто

  • !throws io async (маркер ! слева). Глаз читает !throws как «не throws» — противоположный смысл. К тому же ! стоит только перед первым эффектом, дальше идут «голые» — границы списка не видно.
  • !throws !io !async (маркер на каждом). Шумно, проблема «! как not» остаётся.
  • !{throws, io, async} (явный блок). Фигурные скобки заняты телом функции — путается.
  • <throws, io, async> (Koka-style). Угловые скобки нужны для дженериков (хотя в Nova используется [T], см. 03-syntax.md → D16), читается тяжелее.
  • Атрибуты @throws @io @async. Четыре лишних символа, и @ ассоциируется с метаданными, а не с типом. К тому же @ уже занят методами инстанса (03-syntax.md → D35).
  • Без маркера, всё выводить молча. Опасно — эффекты должны быть видны на глаз в публичном API.
  • Lowercase имена эффектов (throws io async). Отвергнуто в D11 — эффекты обычные типы.
  • : в параметрах (u: User). Заменено на u User — единый стиль (02-types.md → D17).

Связь

  • D2 — эффекты как альтернатива keyword’ам.
  • D11 — имена эффектов как обычные типы, three positions.
  • 02-types.md → D17 — параметры без :.
  • 03-syntax.md → D30 — PascalCase правило для типов.

Эволюция

В первых черновиках синтаксис был fn save(u: User) !throws io async — маркер ! + lowercase эффекты + параметры с :. Каждая из трёх особенностей пересмотрена отдельно:

  • ! отброшен (визуальный конфликт с «not») — этот D3.
  • Lowercase → PascalCase в D11.
  • : в параметрах → u User в 02-types.md → D17.

Главный урок. Символьная пунктуация дёшева на одном месте и дорожает экспоненциально с количеством мест. Слова и структурные границы ()->) масштабируются линейно.


D4. ? для пробрасывания ошибки

Что

Постфиксный оператор ? после выражения — «если ошибка, верни её выше». Работает только в функциях с эффектом Fail[E] в сигнатуре.

Правило

fn pipeline(s str) Fail[ParseError] -> int {
    let n = parse(s)?         // если parse бросил — pipeline бросает то же
    validate(n)?               // если validate бросил — pipeline бросает то же
    n
}

Без Fail в сигнатуре ? — ошибка компиляции:

fn pipeline(s str) -> int {
    let n = parse(s)?           // ОШИБКА: ? requires effect Fail[E]
    n
}

Семантика — сахар над match + throw

expr? компилятор разворачивает в:

match expr {
    Ok(v)  => v
    Err(e) => throw e         // обычный throw, требует Fail[E]
}

Поэтому ? работает только в функциях с Fail[E] — не специальное правило компилятора, а следствие того, что throw сам требует эффект (D25).

Совместимость с Fail[E]

!! пробрасывает ошибку наверх через Fail — тип ошибки в сигнатуре вызывающего должен быть совместим. Если совпадают — проходит напрямую; если разные — нужно явное преобразование через .map_err():

fn pipeline(s str) Fail[PipelineError] -> int {
    let n = parse(s).map_err(|e| PipelineError.Parse(e))!!
    validate(n).map_err(|e| PipelineError.Validate(e))!!
    n
}

Почему

  1. Заимствовано из Rust/Swift, проверено годами использования.
  2. Дешевле try { ... } catch { ... }. Безопаснее if err != nil — нельзя забыть проверку.
  3. Не магия. Полностью разворачивается в существующие конструкции языка (match, throw) — никаких специальных правил.

Что отвергнуто

  • try expr (Swift-style). Слово длиннее, а ? уже знаком всем, кто видел Rust/Swift.
  • expr! для force-unwrap. Конфликтует с логическим «не», и panic-семантика противоречит 08-runtime.md → D13 (panic не ловится в коде).
  • ? без Fail в сигнатуре (с автоматическим выводом). Нарушает правило «public-API явный» (D28). В private может работать через вывод, но даже там удобнее видеть Fail явно.

Связь

  • D25throw как операция эффекта Fail[E], ? разворачивается в throw.
  • D2, D11Fail как обычный эффект.
  • 03-syntax.md → D19match со стрелкой => (используется в desugaring ?).

Coalesce ?? вынесен в D86. Раньше описывался подразделом D4; в 2026-05-10 выделен в самостоятельное решение для возможности независимой эволюции и явных ссылок.

Эволюция

В первой формулировке D4 в исходниках указано «работает в функциях с эффектом throws» (lowercase). Отметка устарела: эффекты в Nova — PascalCase, правильное имя — Fail[E] (раньше Throws[E] — переименование в D61 ради согласованности convention «имя эффекта — существительное в единственном числе», Throws был глаголом, остальные эффекты — существительные).


D11. Имена эффектов и синтаксис with

⚠️ REVISED → D61. Эффект объявляется через keyword effect (type X effect { ... }), не через protocol. Handler-литерал — через keyword handler (effect X { ops }), а не через X { ops }. Раздел оставлен для семантики with-блока (без изменений). Старая формулировка про «protocol-форму» устарела.

Что

Эффект объявляется через keyword effect (см. D61), а handler-литерал — через keyword handler. Имена в PascalCase. Синтаксис with принимает либо имя handler-переменной, либо подмену вида EffectName = expr через запятую, и ровно один блок тела.

Правило

Объявление эффекта

type Logger effect {
    log(msg str) -> ()
}

type Db effect {
    query(q Sql) -> []DbRow
    exec(q Sql)  -> ()
}

Имя эффекта — обычный идентификатор в PascalCase. Объявление через keyword effect (см. D61, D18 (REVISED)).

Имя эффекта в коде — three positions

Имя эффекта в коде может появляться в трёх позициях, каждая разрешается контекстом:

// 1. ПОЗИЦИЯ ТИПА — между ) и -> (или в generic-параметре)
fn process(o Order) Db -> Receipt => ...
//                  ^^ Db — имя типа эффекта

// 2. ПОЗИЦИЯ ОПЕРАЦИИ — Db.X(...) — обращение к операции активного handler'а
Db.query(sql`select * from users`)

// 3. ПОЗИЦИЯ ВЫРАЖЕНИЯ — одиночное Db в выражении
let captured_db = Db          // активный handler как значение Effect[Db]
some_function(Db)
return Db

Парсер различает по позиции, никакой неоднозначности нет. Никакого Db.current() или подобного геттера не существует — просто Db в выражении. Это симметрично тому, как User в выражении не нуждается в User.current().

Форма 1: подмена через EffectName = expr

Основной случай — тесты, переключение реализации:

with Logger = console_logger, Db = in_memory, Time = fixed(t0) {
    process_order(o)
}

После with — список «эффект = handler-выражение» через запятую, потом один { body }. Парсер однозначен: запятые разделяют подмены, { открывает тело.

Форма 2: handler как обычное значение

Для сложных или переиспользуемых handler’ов:

let audit = effect Logger {
    log(msg) { audit_db.write(msg); return () }
}

with Logger = audit {
    critical_operation()
}

EffectName { ... } — выражение-литерал, дающее значение типа Effect[EffectName]. Параллель с record-литералами: разные keyword’ы, разные формы литералов:

type User { id u64, name str }                              // record-тип (data)
let alice = User { id: 1, name: "alice" }                   // record-литерал

type Logger effect { log(msg str) -> () }                  // эффект (behavior)
let console = effect Logger { log(msg) => println(msg) }  // handler-литерал

Handler-литерал начинается с keyword’а handler (по D61) — однозначно отличает от record-литерала. Стрелка в handler-операциях — именно =>, как в match-arms (03-syntax.md → D19) и теле лямбды (03-syntax.md → D22).

Слово handler — keyword (D61)

В первой редакции D11 использовался синтаксис без префикса — Logger { log(msg) => ... }, парсер различал record vs handler по содержимому {...}. После D61 handler стало keyword’ом, а handler-литерал требует явного префикса. Это улучшает локальную читаемость: effect X {...} сразу читается как «литерал handler’а».

Почему

  1. Один блок тела with — нет визуальной путаницы между телом handler’а и телом with-блока.
  2. Несколько эффектов в одном with — естественно и компактно для тестов:
    with Logger = test_log, Time = fixed_clock, Random = seeded(42) {
        run_simulation()
    }
    
  3. Handler — обычное значение, не специальная синтаксическая форма, привязанная к with. Это упрощает композицию — handler’ы можно хранить в переменных, передавать функциям, держать в map.
  4. Симметрия с record-литераламиИмя { ... } для значений любых типов, без специальных префиксов.
  5. with остаётся примитивом языка, а не сахаром над функцией — потому что он структурно влияет на стек handler’ов (continuation capture).

Что отвергнуто

  • with effect Logger { log(msg) => ... } { body }. Два {...} блока подряд читаются плохо: непонятно, где кончается тело handler’а и начинается тело with.
  • handler EffectName = ... keyword. Префикс лишний — содержимое блока (name(args) => body) однозначно говорит, что это handler.
  • Lowercase имена эффектов (throws, io). Эффекты — обычные типы, применяется единое PascalCase-правило (03-syntax.md → D30).
  • Db.current() геттер для активного handler’а. Лишний синтаксис — имя эффекта в выражении и так даёт активный handler.

Связь

  • D2, D3 — эффекты как типы.
  • D18 — эффект объявляется через protocol; литералы различаются по содержимому.
  • D25Fail[E] — частный случай этой схемы.
  • D31 — handler-лямбда (третья форма для эффектов с одной операцией).
  • 02-types.md → D42protocol как структурный контракт; эффекты — это protocol, использованный в позиции эффекта.
  • 03-syntax.md → D19, 03-syntax.md → D22 — стрелка => в match-arms и теле лямбды (та же стрелка в handler-операциях).
  • 03-syntax.md → D30 — PascalCase для типов.

Эволюция

Ранние черновики содержали with effect EffectName { ... } { body } — два {...} подряд и обязательный префикс handler. Пересмотрено на форму без префикса с handler-литералом EffectName { op() => ... } и явную форму подмены EffectName = expr. Lowercase имена эффектов (throws, io) отброшены в пользу PascalCase.


D12. Effect erasure и dynamic effects

Что

Статическая типизация эффектов — дефолт: очереди, каналы, планировщики типизированы по эффектам функций, которые они принимают. Для разнородных задач, плагинов и сериализации есть явные инструменты стирания эффектов и динамики.

Правило

Уровень 1 — статически типизированный планировщик (дефолт)

let order_queue Queue[fn(OrderId) Db Log Fail -> ()]

order_queue.enqueue(send_order_confirmation)        // ок
order_queue.enqueue(cleanup_db_task)                 // ОШИБКА: лишний эффект Net

Воркер этой очереди статически проверен. Лишний эффект не пройдёт. Это правильный дефолт для типизированных пайплайнов.

Уровень 2 — явное стирание через erase[E]

fn erase[E](task fn() E -> ()) E -> fn() -> () =>
    let captured = capture_handlers[E]()
    || with captured { task() }

universal_queue.enqueue(erase(send_email_task))
universal_queue.enqueue(erase(cleanup_db_task))

Эффекты захвачены в момент erase, тип задачи становится fn() -> (), очередь принимает разнородные задачи. Цена: handler’ы зашиты, если они стали невалидными к моменту исполнения — это проблема программиста, не компилятора.

Уровень 3 — динамические эффекты через EffectSet + DynFn

Runtime-структура EffectSet, тип DynFn для случаев, когда эффекты задачи известны только в рантайме (плагины, сериализация в БД). Используется редко, помечается явно.

Что НЕ делается

  • Стирание не автоматическое — иначе строгая типизация превращается в видимость (как Java generic erasure). Программист должен явно попросить erase[E].
  • Все очереди не делаются динамическими по умолчанию — потеряется главное свойство Nova (видимость эффектов в типе).
  • Через границу процесса handler’ы не передаются. Эффекты на этой границе становятся протоколом (имена сервисов, типов сообщений) — это паттерн «commands + dispatcher», не часть системы эффектов.

Почему

  1. Правильный дефолт. 95% случаев — типизированные пайплайны, для них эффекты в типе очереди — гарантия безопасности.
  2. Эскейп-хатч есть, но виден. erase[E] или DynFn — явные маркеры в коде, понятные при ревью. Компилятор не трогает остальные места.
  3. AI-first. LLM, генерируя код, видит явный erase — понимает, что в этой точке статическая безопасность кончается.

Что отвергнуто

  • Автоматическое стирание (Java-style generic erasure). Превращает типизацию в видимость — лишает Nova главного свойства.
  • Все очереди динамические. Каждый enqueue тогда требует runtime- проверки эффектов; типизация в сигнатуре теряет смысл.
  • Эффекты как часть protocol-message через сеть. Handler’ы — это closures с capture, по проводу не передаются. Через границу процесса — обычный паттерн «команды + диспатчер».

Связь

  • D2 — типизация эффектов в сигнатуре.
  • D11 — handler как обычное значение, что позволяет capture через erase.
  • 06-concurrency.md → D14 — fiber runtime, планировщик задач.

Открытые вопросы

  • Конкретный синтаксис capture_handlers[E]() (имя, форма параметра).
  • Семантика EffectSet в рантайме (теги типов? vtable?).
  • Граничные случаи: эффект выходит за scope, handler уже невалиден к моменту исполнения — как сигналить ошибку.

D18. Эффекты объявляются через kind-токен, не голый type

⚠️ REVISED → D53, D61, D62. Финальный синтаксис для эффектов: type X effect { ... }. effect — kind-токен (по D53) И keyword (по D61). Структурные контракты остаются как type X protocol { ... } (см. D62 правило effect/protocol: program-based выбор по двум sniff-вопросам). Различие: effects поддерживают with-substitution и continuation-capture, protocols — нет.

Правило

Чёткое разделение type vs type X effect vs type X protocol

// data — голый type (см. D52)
type User { id u64, name str }
type Color | Red | Green | Blue
type UserId u64

// эффекты (with-substitution + continuation-capture) — kind-токен effect
type Db effect {
    query(q Sql) -> []DbRow
    exec(q Sql)  -> ()
}

type Logger effect {
    log(msg str) -> ()
}

// структурные контракты (без with-substitution) — kind-токен protocol
type Hashable protocol {
    hash() -> u64
    eq(other Self) -> bool
}

Выбор effect vs protocol — программистский (D62 правило 4):

  • with-substitution нужна (mock в тестах)? — effect
  • continuation-capture нужен (throw, interrupt)? — effect
  • Оба «нет» — protocol

type X { методы без полей } запрещено — нужно type X effect { ... } или type X protocol { ... }. Слова effect и handler зарезервированы как keyword’ы; protocol — kind-токен (не зарезервирован как keyword вне type-decl).

Один protocol — две роли по контексту использования

Тот же protocol может работать и как эффект, и как структурный параметр. Различение идёт по позиции в сигнатуре:

type Logger effect { log(msg str) -> () }

// А: позиция эффекта — между ) и ->. Активный handler берётся из скоупа.
fn process_a(o Order) Logger -> () =>
    Logger.log("processing")

// Б: позиция типа значения — обычный параметр, передаётся явно.
fn process_b(o Order, logger Logger) -> () =>
    logger.log("processing")

Программист выбирает стиль:

  • Эффект (А) — для пронизывающих контекстов: БД, лог, аутентификация, трассировка. Не таскается через 10 функций.
  • Параметр (Б) — для явных зависимостей одной функции, когда хочется локальной видимости.

Что осталось без изменений

  • Имя protocol’а в позиции эффекта (между ) и ->) — требование активного handler’а в скоупе.
  • Db.operation(args) — вызов операции активного handler’а.
  • Db в позиции выражения — активный handler как значение (D11).
  • with Db = expr { body } — подмена handler’а в скоупе.
  • Литерал handler’аeffect Db { query(s, a) => ..., exec(s, a) => ... } (через keyword handler, см. D61).

Handler-литерал начинается с keyword handler — это однозначно отличает его от record-литерала. До D61 парсер различал по содержимому {...} (двоеточие vs стрелка); теперь — по prefix’у keyword’а.

Различение литералов

  • Type { name: value } → record-литерал у type (User { id: 1 })
  • effect Type { name(args) => body } → handler-литерал у effect’а (effect Db { query(s, a) => ... })

Стрелка handler-операций — =>, та же что в match-arms (03-syntax.md → D19) и теле лямбды (03-syntax.md → D22). Не ->.

Почему

  1. type для данных, protocol для поведения — единое правило языка (02-types.md → D42). Эффект — это поведение (набор операций без полей), и логично, чтобы он использовал тот же keyword, что и обычные структурные контракты.
  2. Намерение явно по первому токену. Раньше требовалось смотреть на содержимое {...} (поля или методы), чтобы понять, что объявлено. Теперь с keyword видно сразу.
  3. Меньше двусмысленности у LLM. В предыдущей редакции D18 LLM нужно было запоминать «type с одними методами — это контракт/эффект». Сейчас правило прямее: «методы → protocol».
  4. Согласованность с D42. D42 разделил данные и поведение, но эффекты выпадали из правила (объявлялись через type). Этот разворот D18 убирает противоречие.

Что отвергнуто

  • effect X { ... } keyword (как в первоначальной редакции). Не возвращаем — третий keyword рядом с type/protocol плодит сущности без выгоды. protocol уже описывает «именованный набор операций»; эффект — это protocol, использованный в позиции эффекта.
  • handler X = ... keyword. Префикс лишний — содержимое X { op(args) => body } однозначно говорит, что это handler.
  • Сохранить type для эффектов (как в предыдущей редакции D18). Конфликтует с D42: D42 говорит «type — данные, protocol — поведение», а эффект — это поведение. Оставлять эффекты под type — это ровно то противоречие, которое этот разворот D18 устраняет.

Цена

  1. Breaking change для всех ранее написанных примеров эффектов. Все type Db { query, exec }protocol Db { query, exec }. Поскольку реализации компилятора нет, цена — обновление спецификации и примеров.
  2. Семантическая зависимость в парсинге литералов сохраняется. Парсер всё ещё смотрит на содержимое {...} (двоеточие vs стрелка), чтобы различить record-литерал и handler-литерал. Но keyword protocol явно говорит, что у этого имени литерал — handler-форма.
  3. Anonymous structural type в позиции эффектаfn f(x { show() -> str }) сейчас валиден как анонимный protocol в позиции параметра (D42:200-203). Допустим ли он в позиции эффекта между ) и ->? — open question, см. open-questions.md.

Связь

  • D2 — эффекты как protocol’ы, не keyword’ы async/throws/unsafe.
  • D11 — three positions имени эффекта; with-синтаксис для подмены.
  • 02-types.md → D17 — единый синтаксис объявления type (для данных).
  • 02-types.md → D42protocol keyword; эффекты — частный случай protocol, использованного в позиции эффекта.
  • 03-syntax.md → D19 — стрелка => в match-arms, та же что в handler-литералах.

Эволюция

История развода в три шага:

  1. Первая редакция — два keyword’а: effect X { ... } для эффектов, type X { ... } для всего остального.
  2. Вторая редакцияeffect отменён, эффекты объявляются через type. Различение по контексту использования. Этот шаг убрал лишний keyword, но оставил type перегруженным (и данные, и поведение).
  3. Текущая редакция — после D42, который разделил type (данные) и protocol (поведение), эффекты переведены на protocol. type теперь только для данных. Это устраняет противоречие между D18 и D42.

В одном из ранних черновиков D18 пример handler-литерала был записан со стрелкой -> (Db { query(s, a) -> return ... }) — устарело. Стрелка => — единое правило для всех мест, где «образец/параметры → тело» (03-syntax.md → D19, 03-syntax.md → D22).


D25. throw и параметризация Fail[E]

Уточнено D65: Fail без параметра — сахар над Fail[any] (universal), не Fail[Error]. Lookup-правило, re-throw, prelude-типы RuntimeError и Error (record) формально определены в D65. Этот блок (D25) сохраняется как описание базового механизма throw и Fail[E]; полная семантика — в D65.

Что

Бросать ошибку — выражение throw expr, прерывающее функцию через эффект Fail[E]. Параметр E — тип бросаемого значения, обычно sum-type. Fail без параметра — сахар над Fail[any] (universal, catch-all). Convention: для public API использовать Fail[E] с конкретным типом; для quick-and-dirty/scripts/internal helper’ов — Fail (any) допустим. См. D65.

Правило

Базовое использование

type DepositError | Closed | NotPositive | OverLimit

fn deposit(mut acc Account, amount money) Fail[DepositError] -> () =>
    if acc.closed   { throw Closed }
    if amount <= 0  { throw NotPositive }
    acc.balance += amount

throw expr — выражение типа never (никогда не возвращает), прерывает функцию и передаёт expr через эффект Fail[E]. Тип expr должен совпадать с E в сигнатуре.

Bootstrap (2026-05-06): throw парсится и как statement, и как expression. В expression-position (match-arm body, ternary, аргумент функции) codegen эмитирует Nova_Fail_fail(msg) + dummy ((nova_int)0LL) — dummy после fail() недостижим. Тесты — nova_tests/effects/throws.nv (stmt), nova_tests/syntax/throw_in_expression.nv (expr). Известное ограничение bootstrap’а: if cond { throw } else { val } выражение не работает — codegen смешивает unit и реальный тип; workaround — if cond { throw msg } val (throw как stmt, fall-through к val).

throw — операция эффекта Fail[E], не магия

Связь между throw и Fail[E]не специальная проверка компилятора, а прямое применение модели алгебраических эффектов. throw expr — это операция эффекта, точно так же как Db.query(...) или Logger.log(...).

Концептуально prelude объявляет:

type Fail[E] effect {
    fail(value E) -> never        // операция, никогда не возвращает
}

throw expr — сахар для Fail[E].fail(expr). Компилятор разворачивает синтаксический throw в обычный вызов операции эффекта. Дальше работает общее правило для всех эффектов: использовал операцию — задекларируй эффект в сигнатуре.

fn lookup(id u64) Db -> User =>           // Db в сигнатуре — ок
    Db.query(sql`SELECT * FROM users WHERE id = ${id}`)

fn lookup(id u64) -> User =>               // Db отсутствует — ошибка
    Db.query(sql`SELECT * FROM users WHERE id = ${id}`)
//  ^^^^^^^^^^^^^^^^^^^^ operation Db.query requires effect Db

fn parse(s str) Fail[ParseError] -> int =>    // Fail в сигнатуре — ок
    throw ParseError.BadFormat

fn parse(s str) -> int =>                       // Fail отсутствует — ошибка
    throw ParseError.BadFormat
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^ throw requires effect Fail[ParseError]

Никакой отдельной логики для throw нет. Та же проверка, что для Db.query, Net.get, Time.now и любой другой операции эффекта.

? — сахар над match + throw

? тоже не магия. expr? разворачивается в:

match expr {
    Ok(v)  => v
    Err(e) => throw e            // обычный throw, требует Fail[E]
}

Поэтому ? работает только в функциях с Fail[E] в сигнатуре — потому что раскрывается в throw, а throw требует эффект. Отдельного правила «? требует Fail» нет, оно вытекает из обычной проверки эффектов (D4).

never — почему throw совместим с любым типом

throw expr имеет тип never — тип, означающий «не возвращает значение в обычном смысле». never — подтип любого типа (как Nothing в Kotlin/Scala), поэтому throw можно использовать как выражение в любой позиции:

let x int = if condition { 42 } else { throw NotReady }
//                                     ^^^^^^^^^^^^^^^
//                                     тип never, совместим с int

Это работа never, а не специальное правило для throw. То же поведение у return и panic — все три имеют тип never. Поэтому работают и такие выражения:

let user = lookup(id) ?? return Response.error(404)
//                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^
//                       тип never, совместим с типом user

Поймать через ? (проброс) или handler (обработка)

Проброс через ? — сахар «если ошибка, верни её выше». Работает в функциях с совместимым Fail[E] в сигнатуре:

fn pipeline(s str) Fail[ParseError] -> int =>
    let n = parse(s)?              // если parse бросил — pipeline тоже бросает
    n

Обработка через handler — обычный handler-блок. Для Fail[E] основная форма — handler-лямбда (D31), потому что у Fail[E] ровно одна операция (throw):

fn try_deposit(acc Account, amount money) Log -> bool {
    // handler-лямбда (D31) — это лямбда (`=> expr`), без блок-формы
    // (D22). Когда нужен блок с side-effect'ами — используем полный
    // handler-литерал в блок-форме `op(p) { block }`.
    with Fail[DepositError] = effect Fail[DepositError] {
        fail(err) {
            Log.error("deposit failed: ${err}")
            interrupt false
        }
    } {
        deposit(acc, amount)
        true
    }
}

Тип результата with-блока — общий тип всех веток (тело и handler’ы). Здесь оба возвращают bool. Если разнотипные — обернуть в sum-type/ Result/Option.

Две формы handler’а для Fail[E]

Операция Fail[E].fail имеет тип возврата never — она по определению не возвращает значение в точку вызова. Из этого следует, что у handler’а Fail[E] всего два допустимых исхода:

  1. interrupt v → прерывание (with-блок возвращает v). Аналог try/catch в Java. Continuation отбрасывается.
  2. Новый throw → проброс наверх (как другой тип или с обогащением контекста). Управление ищет следующий handler в стеке.

Третья форма — return value / финальное выражение, которая для других эффектов даёт «продолжение с подменой» (управление возвращается в точку вызова операции с подменённым значением), — для Fail запрещена. Тип операции never означает, что в точке throw некуда возвращать значение; type-checker должен это запрещать (см. D61 «never-операции», Q-resume).

Один параметр в Fail[E]

Параметр один. Если функция бросает несколько типов — программист делает sum-type:

type TransferError | InsufficientFunds | InvalidAccount | AccountClosed

fn transfer(from Account, to Account, amount money) Fail[TransferError] Db -> Receipt => ...

Fail без параметра — catch-all (D65)

fn process(o Order) Fail Db {                    // Fail ≡ Fail[any]
    validate(o)
    save(o)
}

export fn process_pub(o Order) Fail[OrderError] Db -> () => ...

Правило (по D65):

  • Fail без [E]Fail[any] (top-type, ловит любую ошибку).
  • В приватных функциях допустим как quick-and-dirty.
  • В публичных функциях допустим, но рекомендуется явный Fail[E] — это часть контракта. Линтер может предупреждать.

Это AI-first компромисс: внутри модуля программист может писать быстро, не придумывая имя ошибки. На границе модуля LLM (и человек) видят конкретный тип в сигнатуре.

⚠️ Изменение в D65. Раньше FailFail[Error] (конкретный record-тип). После D65 FailFail[any] (top-type). Семантика catch-all сохранилась, тип-обёртка Error остался в prelude как удобный record для throw (см. D26).

Связь с Result[T, E]

Fail[E] и Result[T, E]разные инструменты с пересечением сценариев.

fn parse_a(s str) Fail[ParseError] -> int => ...    // эффект-стиль
fn parse_b(s str) -> Result[int, ParseError] => ...   // value-стиль

Когда Fail[E]

  • Прикладной код, где ошибка пробрасывается до handler’а (через ?).
  • Effect-композиция: handler Fail[E] ловится через with, retry/log/централизованная обработка.
  • Несколько функций цепочкой выбрасывают одну и ту же ошибку — чтение становится линейным (x()? .map(f)? без обёрток).

Когда Result[T, E]

  • Значение, которое нужно проинспектировать прямо у вызова (match result { Ok(v) => ..., Err(e) => ... }).
  • API, где обработка ошибки ВСЕГДА происходит локально (не пробрасывается).
  • Возвращаемое значение функции, которая сама по себе не ошибка (например, try_parse возвращает Result намеренно).

Конвертация

Из Fail[E] в Result[T, E]:

let r = with Fail[ParseError] = |e| interrupt Err(e) {
    Ok(parse(s)?)
}
// r: Result[int, ParseError]

Из Result[T, E] в Fail[E]:

let v = parse(s)?              // если Result, ? = match Ok(v) => v / Err(e) => throw e

Оператор ? работает на обоих типах (D26).

Дефолт и AI-first

Дефолт — Fail[E]. Эффект-стиль читается линейно, лучше для LLM-генерации (нет вложенных match’ей). Result — когда сценарий «ошибка как значение, всегда обработать локально».

«Два пути для одного» — кажущееся: пути решают разные задачи. Это не нарушает D40, потому что выбор детерминирован сценарием, не вкусом.

throwpanic

throw expr — обычная ошибка через эффект, видна в сигнатуре через Fail[E]. Перехватывается handler’ом в коде.

panic (08-runtime.md → D13) — аппаратные/ математические сбои (деление на 0, переполнение, OOM, выход за границы массива) или вызов panic(msg) программистом. Не виден в сигнатуре. Не ловится в коде — означает смерть текущего fiber’а, ловится только runtime’ом на границе fiber’а.

Это разные миры:

  • «обработать можно и нужно» → throw + Fail[E]
  • «обработать никак нельзя, fiber умирает» → panic

Почему

  1. throw — обычная операция эффекта, не специальная конструкция. Минус один концепт — throw объясняется через тот же механизм, что Db.query и Logger.log.
  2. Тип ошибки в сигнатуре — AI-first: LLM видит конкретный класс ошибок, не общий «может бросить что-то».
  3. throw известно из Java/JS/C#/Swift — AI-friendly без переучивания.
  4. Sum-type для нескольких ошибок — простая композиция handler’ов: один handler ловит весь sum-type, дальше match по вариантам.

Что отвергнуто

  • raise или error() вместо throw. throw известно по умолчанию из мейнстримных языков.
  • Fail всегда без параметра (как Java unchecked exceptions или Swift throws). Теряется видимость типа ошибки в сигнатуре, ломает AI-first тезис.
  • Fail[E1, E2, E3] (множественные параметры). Усложняет композицию handler’ов (handler ловит «один из E1/E2/E3»? все три? один с union-pattern’ом?). Семантически избыточно — sum-type выражает то же чище. Нарушает простое правило «один эффект — один параметр», как Alloc[R], Ask[T].
  • throw без эффекта в сигнатуре (как Java RuntimeException). Невидимое control flow — главная проблема Java unchecked exceptions.

Связь

  • D2 — эффекты вместо keyword’ов; Fail[E] — один из эффектов.
  • D4? как сахар над match + throw.
  • D11 — handler-литералы для Fail[E].
  • D31 — handler-лямбда для Fail[E] (главный case сахара).
  • 02-types.md → D15 — sum-types для нескольких типов ошибок.
  • 08-runtime.md → D13throwpanic.

Цена

  1. Программист обязан явно описывать тип ошибки в публичных API — дополнительная работа, оправданная видимостью контракта.
  2. Sum-type для нескольких типов ошибок — небольшой синтаксический налог, оправданный простотой композиции handler’ов.
  3. Граница throw vs panic требует понимания — лечится документацией.

Performance: насколько дорогой throw

Bootstrap-runtime реализация throw msg:

  1. Vtable indirect call: _nova_handler_Fail->fail(ctx, msg) — один pointer-load + indirect-call. ~1ns на современном CPU.
  2. Handler-method body — пользовательский Nova-код. Зависит.
  3. longjmp на nearest fail-frame: restore callee-saved regs, sp, pc. ~10-20ns. Без RAII-unwind (D6 GC — нет destructor’ов).
  4. Cross-mco-boundary (если throw в fiber, handler снаружи): запись pending в scope-state, longjmp на fiber-local fail-frame, потом scope-runner re-issue на main. Дополнительно ~10-20ns.

Итого: ~50-200ns на throw без stack-trace. Дёшево.

Сравнение:

ЯзыкCost throw
Java exceptions10000-50000ns (stack-trace fill-in + class lookup)
C++ exceptions1000-10000ns (zero-cost happy path, expensive throw)
Rust panic1000-10000ns (similar to C++)
Go panic100-500ns (similar approach to Nova)
Nova throw~50-200ns (без stack-trace, без RAII)

Когда throw становится узким местом:

Hot loop с throw на каждой итерации (парсер где throw для каждого invalid char) — даже 100ns × 10⁶ итераций = 100ms. В таком случае использовать Result-стиль через D77 try_from/try_into: match на Result в hot path вообще не использует longjmp.

Throw — для business-level errors, где он редок и acceptable. Result — для парсинга / валидации / hot path. Это рекомендация из D73 (from/into для use-cases, try_from/try_into для implementation хотя оба доступны вызывающему). и сообщениями компилятора.

Эволюция

В первых черновиках допускалось Throws[E1, E2] (множественные параметры) — пересмотрено в пользу sum-type. Также раньше Throws без параметра был всегда допустим, теперь — только в приватных функциях (D28). Эффект переименован ThrowsFail в D61.


D28. Вывод эффектов: private — выводится, public — обязательно явно

⚠️ REVISED → D62. Изначально D28 объявлял «полный транзитивный вывод эффектов = compile error при missing в public». После D62 вывод прямых эффектов остался обязательным (compile error если не объявлены), но транзитивные эффекты теперь дают warning (suppressable через #allow_transit(...) или Nova.toml). «Чистая функция = проверенный факт» теперь работает как «прямой эффект отсутствует» — функция может транзитивно делать Db.exec, но если она сама не вызывает Db.X, она формально без Db в сигнатуре. Гарантия чистоты ослаблена; для жёсткой санитизации использовать forbid X { }.

Что

Эффекты в сигнатуре private-функций (без export) выводятся компилятором для прямых вызовов; транзитивные — warning. Программист может опустить прямые эффекты в private, компилятор проанализирует тело и добавит. В public API (export fn) прямые эффекты обязательны явно — это контракт.

Функция без прямых эффектов — это проверенный факт об отсутствии прямых обращений к эффект-операциям. Транзитивные обращения через вложенные вызовы возможны (но виден warning по D62).

Правило

Базовое использование

// private — эффекты выводятся
fn helper(x int) =>
    Logger.log("processing ${x}")     // компилятор добавит Logger в сигнатуру
    x * 2

// то же явно — программист тоже может писать
fn helper(x int) Logger -> int =>
    Logger.log("processing ${x}")
    x * 2

// public — должно быть явно
export fn process(x int) Logger -> int =>
    Logger.log("...")
    x * 2

// public БЕЗ эффектов — компилятор проверяет, что их и правда нет
export fn double(x int) -> int =>
    x * 2                              // ок, чистая

export fn bad(x int) -> int =>
    Logger.log("...")                  // ОШИБКА: эффект Logger не объявлен
    x * 2

Гарантия отсутствия прямых эффектов

Функция без эффектов в сигнатуре (после D62) = компилятор доказал, что она сама не использует эффект-операции:

  • Не вызывает Io.read/print (но может вызывать функцию, которая внутри это делает — warning)
  • Не делает прямых вызовов на сеть/БД/файлы
  • Не делает throw или ? (Fail strict — транзитивный, всегда виден)
  • Не аллоцирует в region’ах с эффектом

Это слабее «полной чистоты» (которая была в D28 до D62). Для жёсткой гарантии «вызовы суда не доходят» — использовать forbid X { ... } capability sandbox.

Что осталось strict:

  • Fail[E] — всегда транзитивный, обязан быть в сигнатуре если callee может бросить (D65).
  • Прямые вызовы — compile error если эффект не объявлен.

Правило вывода (после D62)

Компилятор анализирует тело функции:

  1. Использование операции эффекта (Db.query, Logger.log) прямо в теле → этот эффект добавляется (обязательный для public).
  2. Каждый throw или expr?Fail[E] добавляется (всегда транзитивный, см. D65).
  3. Каждый вызов функции с эффектами в чужой сигнатуре
    • Fail транзитивно добавляется (strict).
    • Другие эффекты — warning «не объявленный транзитивный X», suppressable через #allow_transit(X).
  4. Мутация @field в mut @method (03-syntax.md → D35) — это mut-метод, не эффект (D62 убрал Mut).

Public API — почему обязательно явно

  1. Контракт модуля. Сигнатура — это интерфейс, который другие модули видят. Изменение эффектов = breaking change. Должно быть видно в коде, не выводиться невидимо.
  2. AI-first. LLM, читая сигнатуру публичной функции, должна видеть все побочные действия. Public API — точка, где «сигнатура = полное описание» работает.
  3. Документация. Public — это то, что попадает в nova doc. Эффекты — часть документации, не runtime-деталь.
  4. Случайное расширение. Если private-функция получила лишний эффект (программист добавил Logger.log в утилиту), это не должно автоматически попадать в public — public видит ошибку компиляции, программист принимает осознанное решение.

Случайное расширение в private — после D62

Программист добавил вызов Logger.log(...) в утилиту → у функции автоматически появился Logger (прямой) → вызывающие private-функции получают warning «транзитивный Logger не объявлен», но компилируются. До public API доходит warning, не ошибка.

Это ослабление D28 в пользу удобства. Если нужна жёсткая проверка «функция не должна косвенно делать X» — использовать forbid X { ... }:

fn pure_view(u User) -> str =>
    forbid Db, Net, Io {
        format_user(u)         // compile error если внутри есть Db/Net/Io
    }

Тулинг:

  • nova check --show-effects — режим, показывающий выведенные эффекты для всех private-функций.
  • @no_effects атрибут на private-функцию — компилятор обязан подтвердить, что функция чистая. Если нет — ошибка.
  • @effects(Logger, Db) атрибут — закрепить ожидаемые эффекты для private. Расширение → ошибка.

В release-сборке тулинг не нужен, в dev — стандартный механизм проверки.

Историческая заметка про Async

В первой редакции D28 здесь был раздел «Async — особенно важно», обсуждавший «сделать ли Async дефолтным эффектом». После D62 Async вообще не эффект (ambient runtime-инфраструктура, см. D14), поэтому дилемма не актуальна.

Возникал вопрос «сделать Async дефолтным для всех функций, чтобы не писать его в каждой backend-сигнатуре». Отвергнуто в пользу полного удаления Async из системы типов:

Чистая функция double(x int) -> int гарантированно не приостанавливается. Можно использовать в hot loop без yield-pauses. Если бы Async был дефолтом — этой гарантии бы не было.

D28 решает «шум Async» иначе: в private он выводится, программист не пишет. В public — пишет один раз. Гарантии чистоты сохраняются.

Почему

  1. AI-first компромисс. Внутри модуля программист пишет быстро, на границе модуля LLM (и человек) видит явный контракт.
  2. Гарантия чистоты сохраняется. Public-функция без эффектов — проверенный факт, можно мемоизировать.
  3. Шум Async уходит. В private его не пишут, в public — один раз для каждой границы.

Что отвергнуто

  • Везде явно (как Java checked exceptions). Шум в private-утилитах без выгоды.
  • Везде выведено (как Haskell для типов). Public API теряет явный контракт.
  • Async как эффект (любой формы — дефолт или явно). Отвергнуто в D62: suspension — runtime-факт, не type-fact.
  • Опт-ин для вывода (@infer_effects или подобный). Программист выбирает каждый раз — лишний шум.

Связь

  • D2 — эффекты вместо keyword’ов; D28 уточняет правило вывода.
  • D25 — то же правило для Fail: выводится в private (можно опустить параметр), обязателен в public.
  • 01-philosophy.md → D10 — AI-first: видимость в public API сохраняется, шум в private убирается.
  • 07-modules.md → D5 — два уровня видимости (export / приватно), эффект-видимость следует видимости функции.

Цена

  1. Качество сообщений компилятора при ошибке «private-функция приобрела эффект, public-вызывающий не объявлен» — критично. Программист должен видеть где эффект пришёл, через какую цепочку вызовов.
  2. Цепные изменения в private — диф не показывает явно, что эффекты расширились. Тулинг (--show-effects, @no_effects) компенсирует.
  3. Compile-time стоимость — анализ эффектов транзитивный, увеличивает время компиляции на несколько процентов. Приемлемо.

D31. Handler-лямбда для эффектов с одной операцией

Обновлено D61 и D22-rev (2026-05-10): синтаксис Fail[E] ушёл в Fail[E], protocol для эффектов ушёл в effect, handler-литерал получил keyword handler. Тело handler-method’а завершается через return v / финальное выражение или interrupt v (см. D61). Handler-лямбда мигрирована с (args) => expr на |args| expr симметрично closure-rev — единый «pipe-маркер» для всех безымянных функций.

Что

Если эффект имеет ровно одну операцию, handler можно записать как handler-лямбду в форме |args| body — параметры соответствуют параметрам единственной операции эффекта. Для эффектов с двумя и более операциями — handler-литерал effect EffectName { ... } обязателен.

Handler-литерал effect EffectName { op(p) ... } содержит handler-методы, у которых две взаимоисключающие формы тела, как у fn (D40): op(p) => expr (одно выражение) или op(p) { block } (блок-форма без =>). Сочетание => и {} в handler-method запрещено — правило симметрично D40.

Правило

Базовое использование

type Fail[E] effect {
    fail(value E) -> never
}

// сокращённо — handler-лямбда (одна операция → |args| body)
// Тело должно содержать `interrupt v` (поскольку fail возвращает
// never, нормальное завершение через return невозможно):
with Fail[Error] = |err| interrupt log_and_default(err) {
    Db.exec(sql`UPDATE accounts SET balance = balance - 1`)
}

// полная форма — эквивалентна
with Fail[Error] = effect Fail[Error] {
    fail(err) => interrupt log_and_default(err)
} {
    Db.exec(sql`UPDATE accounts SET balance = balance - 1`)
}

Тело handler-лямбды — bare expression или block (как у closure-light, D22):

// expression-body — типичный случай
with Fail[Error] = |err| interrupt default_value { ... }

// block-body — несколько statement'ов
with Fail[Error] = |err| {
    Log.error("got error: ${err}")
    interrupt default_value
} { ... }

|err| -1 без interruptневалидно для Fail[E]-handler’а: операция fail(value E) -> never запрещает return/финальное выражение (нет значения типа never), требуется явный interrupt. Старая форма без interrupt соответствовала pre-D61 implicit-interrupt семантике — она отвергнута в D61 как AI-unfriendly.

Для эффекта с несколькими операциями — handler-литерал обязателен. Handler-method может быть как => expr, так и блок-формы { block }:

type Db effect {
    query(q Sql) -> []DbRow
    exec(q Sql)  -> ()
}

with Db = effect Db {
    // короткая форма — одно выражение
    query(q) => real.query(q)

    // блок-форма — несколько statement'ов
    exec(q) {
        staged.push(q)
        return ()
    }
} {
    transfer(alice, bob, 100)
}

Запрещено (нарушает D40 «=> и {} не сочетаются»):

with Db = effect Db {
    exec(q) => {                          // ← запрет: => { block }
        staged.push(q)
        return ()
    }
}

Какие эффекты попадают под сахар

Из стандартного набора:

ЭффектОперацииСахар работает
Fail[E]fail(value)да — главный win
Randomnext()да (если одна операция)
Logger (минимальный)log(msg)да
Timenow(), sleep(d)нет
Dbquery, execнет
Netget, post, …нет
Fsread, write, …нет
Пользовательскиезависитесли одна

Fail[E] — самый частый случай, ради него сахар главным образом вводится. В backend-коде with Fail[E] = |err| ... { ... } будет основной формой обработки ошибок через handler.

Грамматика

В позиции значения после with EffectName =:

  • Handler-лямбда |params| body (где body — expression или block, по D22-rev) → сахар, разворачивается в handler-литерал с одной операцией. Компилятор проверяет, что у эффекта ровно одна операция, и параметры лямбды совместимы с её сигнатурой.
  • No-arg handler-лямбда || body — для операций без параметров (например Random.next() -> int).
  • Handler-литерал effect EffectName { op(p) => expr, op(p) { block }, ... } → используется как есть. Работает для любого числа операций. Каждый handler-method — => expr или { block }, никогда не вместе (D40).
  • Переменная или выражение типа Effect[EffectName] или Effect[EffectName, IRT] (D87) → используется как есть.

Парсер однозначен по первому токену после =:

  • | (pipe) → handler-лямбда (по closure-light grammar D22)
  • || → handler-лямбда без параметров
  • handler (keyword) → handler-литерал
  • идентификатор → переменная/выражение

В отличие от обычной closure-light, закрытие в этой позиции интерпретируется как handler-лямбда — компилятор смотрит на ожидаемый тип Effect[EffectName] и:

  • проверяет что эффект имеет ровно одну операцию,
  • сопоставляет параметры лямбды с параметрами этой операции,
  • разворачивает в полный handler-литерал.

Что компилятор проверяет

// ОК — эффект с одной операцией
type Logger effect { log(msg str) -> () }
with Logger = |msg| println(msg) { ... }

// ОШИБКА — у Db две операции, лямбда неоднозначна
with Db = |sql| ... { ... }
//        ^^^^^^^^^^^
//        error: handler-lambda requires effect with exactly one operation
//               (Db has 2: query, exec)
//        suggestion: use handler literal — effect Db { query(...) => ..., exec(...) => ... }

// ОШИБКА — параметры лямбды не совпадают с операцией
with Fail[Error] = || { ... } { ... }
//                  ^^^^^^^^^^^^
//                  error: handler-lambda parameter count mismatch
//                         expected one parameter (value Error), got zero

Почему

  1. Главный win — Fail[E] обработка. В backend-коде with Fail[E] = |err| ... { ... } повторяется в каждой обработке ошибок. Сахар сокращает в 2-3 раза без потери семантики.
  2. «Минимум строк на выходе» — один из центральных принципов Nova (01-philosophy.md → D10).
  3. Граница сахара чёткая — только в позиции with EffectName =, только для эффектов с одной операцией. Не превращается в общую SAM-conversion.
  4. Симметрия с closure-rev. После D22-rev |x| — единый «pipe-маркер» для всех безымянных функций (closure как value, closure как arg, handler-лямбда). Программист учит одну грамматику.

Что отвергнуто

Полная SAM-conversion (любой type с одной операцией → лямбда). Это разрешало бы лямбды и для не-эффектных типов:

type Comparator { compare(a int, b int) -> int }
let c Comparator = |a, b| a - b      // ← отвергнуто, не делаем

Причина: для эффектов сахар сильно окупается (Fail частый, минимизация строк критична). Для обычных типов дублирует функциональный тип fn(int, int) -> int без выгоды. Граница чёткая — сахар работает только в позиции with EffectName =.

Handler-лямбда через (params) => (форма до 2026-05-10) — заменена на |params| body ради симметрии с closure-rev. => освобождён от роли «лямбда-стрелки» и остаётся маркером тела named fn / handler-method / match-arm.

Связь

  • D11 — добавляет третью форму handler’а (помимо литерала и переменной).
  • D25throw как операция эффекта Fail[E], лямбда естественно её перехватывает.
  • 03-syntax.md → D22 — closure-light |x| body. Handler-лямбда — специализация в позиции with EffectName = для эффектов с одной операцией.
  • 03-syntax.md → D40 — handler-method подчиняется тому же правилу =>{}, что и fn: или => expr, или { block }, никогда не вместе.
  • 03-syntax.md → D43 — trailing-block с обязательными () (сюда не применяется — здесь handler-выражение после =, не trailing-block).

Цена

  1. Парсер чуть сложнее — после with X = нужно различить handler-лямбду (|...|), handler-литерал (handler-keyword) и переменную. Каждый случай распознаётся по первому токену.
  2. Breaking change при добавлении операции — если эффект расширили, все handler-лямбды для него ломаются с compile error. Это корректное поведение (видимое нарушение контракта), но программисту нужно обновить код в нескольких местах.
  3. Два способа делать одно и то же. Сахар (|params| body) и полная форма (effect EffectName { op() => ... }). Линт может предлагать сахар где он короче.

Эволюция

Ранее в open-questions.md была отрицательная запись «SAM-conversion отвергнут». Сейчас пересмотрена: SAM принят с ограничением — только для эффектов в with, только при одной операции. Главный аргумент пересмотра: «минимум строк на выходе» — with Fail[E] = effect Fail[E] { fail(err) => ... } повторяется в каждой обработке ошибок.

Ревизия (2026-05-10): handler-лямбда мигрирована с (params) => expr на |params| body симметрично closure-rev D22. Тело теперь может быть expression ИЛИ block (раньше — только expression). Семантика не изменилась. Migration: ~15 примеров в spec.


D61. Полная семантика эффектов: effect keyword, handler-литерал, Effect[E], interrupt

Что

Закрывающий блок системы эффектов. Фиксирует:

  1. type Foo effect { ... } — отдельный keyword для объявления типа эффекта (вместо ранее использовавшегося protocol).
  2. effect Foo { ... } — keyword для handler-литерала (значения, реализующего эффект).
  3. Effect[E] — тип значения handler-литерала, first-class.
  4. Effect-row — неупорядоченное множество, дубликаты запрещены.
  5. return v / финальное выражение в handler-method — нормальное завершение, значение идёт в caller операции (continuation возобновляется).
  6. interrupt v — досрочное завершение всего with-блока, новый keyword.
  7. tail-position для return / interrupt — код после запрещён.
  8. Effect[E].op(args) — прямой вызов операции на handler-значении, минуя with-стек.
  9. Тип with-блока — единый тип T, который дают и финальное выражение body, и все handler-method’ы (когда они не делают interrupt).
  10. Алгоритм компиляции/интерпретации — пошаговое тех-задание для имплементатора (раздел ниже).

Этот блок закрывает Q-resume-semantics и Q-handler-method-param-inference.

Правило

1. type Foo effect { ops } — объявление эффекта

type Db effect {
    query(q Sql) Fail[DbError] -> []DbRow
    exec(q Sql)  Fail[DbError] -> int
    in_transaction[T](body fn() Db Fail -> T) Fail -> T
}

Generic-методы в effect-объявлении (например, in_transaction[T]) требуют rank-2 polymorphism — один handler работает с любым T для каждого вызова. Точная семантика type-checker’а для rank-2 в effect- методах — открытый вопрос (Q6). Bootstrap- интерпретатор поддерживает через runtime erasure (T мономорфизуется как any на уровне dispatch’а); production-компилятор должен дать формальное правило.

type Logger effect {
    log(msg str) -> ()
}

type Fail[E] effect {
    fail(value E) -> never
}

Раньше эффекты объявлялись через type X protocol { ... } (D18, D53). Теперь — отдельный keyword effect. Причина: эффект и protocol — семантически разные контракты:

  • protocol — структурный интерфейс, проверяется на типе значения параметра (fn sort[T Hashable](xs []T), D72). Статический dispatch.
  • effect — контракт на наличие активного handler’а в скоупе (fn save() Db -> ()). Lookup в with-стеке, динамический dispatch.

Смешение запрещено:

  • fn f[T Db](x T) — compile error: Db это эффект, не protocol.
  • fn f() Hashable -> () — compile error: Hashable это protocol, не effect.

2. effect Foo { ops } — handler-литерал

Значение, реализующее эффект Foo. Появляется в let-биндинге, в with X = ..., в return-position функций, в аргументах:

// Место 1 — let-биндинг
let postgres_db = effect Db {
    query(q) => real_query(q)
    exec(q)  => real_exec(q)
    in_transaction(body) => real_transaction(body)
}

// Место 2 — внутри with
with Db = effect Db {
    query(q) => []
    exec(q)  => 0
    in_transaction(body) => body()
} {
    process()
}

// Место 3 — return из функции (декоратор)
fn with_audit(real Effect[Db]) -> Effect[Db] => effect Db {
    query(q) => real.query(q)
    exec(q) {
        spawn write_audit(q)
        real.exec(q)
    }
    in_transaction(b) => real.in_transaction(b)
}

// Место 4 — аргумент функции
fn run_with(h Effect[Db], body fn() Db -> ()) -> () {
    with Db = h { body() }
}

Handler-литерал содержит handler-method’ы — по одному на каждую операцию эффекта. Тело handler-method’а — => expr или { block }, как у fn (D40).

3. Effect[E, IRT] — тип значения

Effect[E, IRT] — встроенный generic-тип, не объявляется в пользовательском коде. Параметризован эффектом E и типом interrupt’а (IRT — interrupt-return type), полностью описан в D87.

Effect[E]Effect[E, never] — sugar (через D88 default generic) для handler’а, который не делает interrupt.

Источники значений:

  • handler-литерал effect EffectName { ops } — выражение типа Effect[E, IRT] (IRT inferred из interrupt’ов в теле; если их нет — never)
  • handler-лямбда |args| body для одно-операционных эффектов (D31)

Effect[E, IRT] — first-class:

let h = effect Db { ... }                  // h: Effect[Db, never] (нет interrupt)
let arr = [h, h2, h3]                       // в массив
let pair = (h, "label")                     // в кортеж
fn make() -> Effect[Db] => h               // вернуть из fn (never по default)
fn use(h Effect[Db]) { ... }               // принять как параметр

// Handler с interrupt типа int:
fn make_fatal() -> Effect[Logger, int] => effect Logger {
    log(msg) {
        if msg.starts_with("FATAL") { interrupt -1 }
        println(msg)
    }
}
Effect-type vs Effect[E] — где какой использовать

Это два разных типа, и компилятор различает их по позиции:

ТипГде допустимЧто значит
Foo (effect-тип сам по себе)effect-position сигнатуры (между ) и ->), позиция эффекта в with X = ...контракт «нужен handler в скоупе»
Effect[Foo] (тип значения)позиция типа значения: тип переменной, тип параметра, тип returnконкретное handler-значение, которое можно передавать

Конкретные правила:

  • let h Fail[Error] = ...compile error: Fail[Error] не тип значения. Должно быть let h Effect[Fail[Error]] = ....
  • fn f() Fail[Error] -> () — OK: Fail[Error] в effect-position.
  • fn make() -> Effect[Fail[Error]] => effect Fail[Error] { ... } — OK: возвращаемый тип = Effect[Fail[Error]], литерал даёт это значение.
  • fn run(h Effect[Fail[Error]]) -> () { with Fail[Error] = h { ... } } — OK: параметр-handler в позиции типа значения, в with — effect-тип.

Эта строгая разделённость позиций — не ради «чистоты», а ради disambiguation при чтении. Один и тот же синтаксический токен Foo парсится в effect-row или в обычной type-position, и эти позиции грамматически различимы. Правило «compile error при попытке смешать» — gatekeeper, чтобы случайные ошибки ловились на type-check.

4. Effect-row неупорядочен, дубликаты запрещены

fn process(o Order) Db Logger Fail[E] -> ()
fn process(o Order) Logger Db Fail[E] -> ()       // та же сигнатура

Effect-row — множество, не список. Порядок не определяет сигнатуру. Lookup в with-стеке индексирует по имени типа эффекта.

Дубликаты одного и того же эффекта — compile error:

fn bad() Db Db -> ()                         // ОШИБКА: duplicate effect `Db`

Разные параметры одного generic-эффекта — разрешены (D65):

fn process(s str) Fail[ParseError] Fail[RuntimeError] -> int { ... }
                                             // ОК: multi-Fail в row,
                                             // см. D65

Это применимо ТОЛЬКО к параметризованным эффектам, у которых разные type-аргументы дают разные effect-роли. Для Fail[E] — это canonical паттерн composition’а (см. D65).

Convention для записи: алфавитный порядок или по «частоте использования» (программистский выбор), но это convention, не grammar.

5. Завершение handler-method’а — return / финальное выражение

Handler-method ведёт себя как обычная функция. Возвращает значение в caller операции (continuation возобновляется с этим значением):

effect Db {
    query(q)  => real_query(q)               // финальное выражение = return
    exec(q)  {
        let r = real_exec(q)
        return r                              // явный return
    }
}

С точки зрения caller’а операции — это обычный возврат:

let rows = Db.query(q)                       // получает результат query
println(rows.len())                           // программа продолжается

6. interrupt v — досрочное завершение with-блока

Когда handler-method хочет прервать continuation и сделать так, чтобы вместо вызова операции из Db.query(...) весь with-блок сразу вернул v — используется interrupt v:

effect Fail[E] {
    fail(err) => interrupt -1               // throw перехвачен; with-блок отдаёт -1
}

effect Db {
    query(q) => real_query(q)                // обычное завершение
    exec(q) {
        if dangerous(q) {
            interrupt 0                       // прервать с 0, не выполнять SQL
        }
        real_exec(q)
    }
}

Семантика:

  • interrupt v валиден только внутри handler-method’а. Вне — compile error.
  • После interrupt v continuation не возобновляется. Значение v становится результатом всего with-блока.
  • Handler-method, в котором сработал interrupt, считается завершённым. Code после interrupt в той же ветке — compile error (мёртвый код).

Тип аргумента interrupt v — это тип with-блока (W), не return-тип операции. Компилятор знает W через type inference сверху вниз для всего with-блока (см. раздел «Тип with-блока» ниже). Для каждого handler-method’а:

Путь завершенияТип v должен быть
return v или финальное выражениеreturn-тип операции (R из декларации)
interrupt vтип with-блока (W)

Это разные типы: R определяется effect-декларацией статически, W — контекстом where the with appears. Один handler-method может смешивать оба завершения в разных ветвях:

type Db effect {
    query(q Sql) -> []DbRow      // R = []DbRow
}

let result = with Db = effect Db {
    query(q) {
        if q.template == "" {
            interrupt 42          // здесь v: int (W = int — см. body ниже)
        }
        real_query(q)             // здесь финальное выражение: []DbRow (R)
    }
} {
    let rows = Db.query(some_q)
    rows.len()                       // body даёт int → W = int
}
// result: int

Чтобы это валидно проходило type-check:

  • В ветке interrupt 4242: int, совместимо с W = int. ✅
  • В ветке real_query(q)[]DbRow, совместимо с R = []DbRow. ✅
  • Body даёт rows.len: int, совместимо с W = int. ✅

7. Tail-position для return и interrupt

После return v или interrupt v в той же ветке — код запрещён (аналогично D23 для return в обычной функции):

exec(q) {
    return real_exec(q)
    println("dead")                           // ОШИБКА: код после return недостижим
}

exec(q) {
    if dangerous(q) {
        interrupt 0                            // OK — последняя инструкция в ветке
    } else {
        return real_exec(q)                   // OK — последняя инструкция в ветке
    }
    // OK — код после if/else возможен, если хотя бы одна ветка не выходит
}

В match каждая arm — отдельная tail-position. То же что для обычных функций.

8. never-операции и interrupt

Операция типа never (классический пример — Fail.throw) не имеет валидных значений возврата. Поэтому в её handler-method’е:

  • return v запрещён (нет значения типа never).
  • Финальное выражение запрещено.
  • Единственный валидный путь — interrupt v, где v имеет тип результата with-блока.
effect Fail[Error] {
    fail(err) => interrupt log_and_default(err)     // OK
}

effect Fail[Error] {
    fail(err) => err.message                        // ОШИБКА: return запрещён для never
}
throw expr — keyword-сахар над Fail[E].fail(expr)

Keyword throw expr — синтаксический сахар над операцией fail эффекта Fail[E]:

throw expr
// разворачивается в
Fail[E].fail(expr)

Семантика:

  • Тип throw exprnever (как и операция fail).
  • Требует активный handler для Fail[E] где-то выше по стеку. Без него runtime panic «no handler for effect Fail[E]» (либо compile error, если static-анализ доказал что handler никогда не активен).
  • Type checker проверяет, что в эффект-row enclosing-функции есть Fail[E] (или эффект может быть выведен через D28 для private-функций).
  • Тип E для throw expr определяется типом expr (или явной параметризацией если Fail[E] указан в сигнатуре с конкретным E).

Связь с ? (D4):

expr?
// разворачивается в
match expr {
    Ok(v)  => v
    Err(e) => throw e        // throw e ≡ Fail[E].fail(e)
}

То есть ? это сахар над match + throw, а throw — сахар над Fail[E].fail(...). Никаких специальных правил компилятора для throw или ? — всё через стандартный effect-механизм.

never как тип значения, совместимый с любым

never — bottom-тип (02-types.md → D26). Значений типа never не существует, но позиция типа never совместима с любым другим типом при type-check. Это нужно потому что:

  • throw expr (тип never) может стоять в любой позиции: let x int = throw e, if cond { throw e } else { 42 }, и т.д.
  • Body with-блока, который всегда заканчивается throw’ом, имеет тип never. Тогда тип with-блока = тип interrupt-веток handler’а (потому что body не возвращает значение нормально).

Пример:

let i = with Fail[Error] = effect Fail[Error] {
    fail(err) => interrupt -1
} {
    throw Error.new("bad")           // тип throw — never
}
// type of i = int
//   body's type = never (всегда throw)
//   handler interrupt's type = int
//   объединение: int (never совместим с int)

Алгоритм типизации with-блока:

  • T_body — тип финального выражения тела (может быть never).
  • T_handler[i] — тип каждого interrupt v пути в каждом handler-method’е.
  • W (тип всего with-блока) = наименьший общий тип всех T_body и всех T_handler[i]. never поглощается любым типом (то есть lub(never, T) = T для любого T).

9. Прямой вызов h.op(args) на handler-значении

Handler-значение поддерживает прямой member-call к своим операциям:

let real = effect Db { query(q) => real_query(q), ... }
let rows = real.query(sql`SELECT 1`)         // прямой вызов на handler-значении

Семантика:

  • h.op(args) исполняет handler-method op на значении h напрямую.
  • Минует with-стек — runtime не ищет handler по имени, использует именно h.
  • Handler-method’ы внутри h могут использовать другие эффекты (через свой собственный with-скоуп, или через прямой вызов на ещё одном handler-значении).
  • interrupt v внутри h.op(args) прерывает этот вызов, не enclosing-with-блок. Значение v становится результатом h.op(args).

Это нужно для handler-декораторов:

fn with_audit(real Effect[Db]) -> Effect[Db] => effect Db {
    query(q) => real.query(q)                // вызываем real напрямую
    exec(q) {
        spawn write_audit(q)
        real.exec(q)                          // вызываем real напрямую
    }
}

h.op(args) — не сахар для with E = h { E.op(args) }, это разные механизмы с разной семантикой при вложенных вызовах:

  • real.exec(q)real не попадает в with-стек. Если внутри real есть Db.exec(...), он найдёт handler из внешнего скоупа.
  • with Db = real { Db.exec(q) }real кладётся в стек как активный Db. Вложенный Db.exec(...) внутри real рекурсивно снова попадёт в real.

Для handler-декораторов это критично: если бы with_soft_delete использовал with Db = real { ... } вместо real.exec(q), любой вложенный вызов Db.exec(...) внутри real снова проходил бы через with_soft_delete — бесконечная рекурсия. Прямой вызов явно говорит «вызови именно этот handler-объект, не ищи в стеке».

Без прямого вызова декоратору пришлось бы оборачивать в with:

exec(q) {
    spawn write_audit(q)
    with Db = real { Db.exec(q) }            // длиннее, и семантика другая
}
Канонический пример: handler через переменную

Тип-разграничение «Foo в effect-position vs Effect[Foo] в value-position» лучше всего видно на примере, где handler сначала кладётся в переменную, а потом передаётся в with:

fn make_recovery() -> Effect[Fail[Error]] => effect Fail[Error] {
    fail(err) => interrupt -1
}

let h = make_recovery()                    // тип h: Effect[Fail[Error]]

let i = with Fail[Error] = h {              // Fail[Error] здесь — effect-position
    throw Error.new("not good")
}
// тип i = int (через never-совместимость + interrupt -1)

По строкам:

  • Effect[Fail[Error]] — return-тип фабрики (позиция типа значения).
  • effect Fail[Error] { ... } — handler-литерал, выражение типа Effect[Fail[Error]].
  • let h = make_recovery() — биндинг handler-значения. Тип переменной выводится: Effect[Fail[Error]].
  • with Fail[Error] = h { ... }Fail[Error] в effect-position (контракт), h — конкретное handler-значение.
  • throw Error.new("not good") — keyword throw раскрывается в Fail[Error].fail(Error.new("not good")). Тип throw-выражения = never.
  • interrupt -1 в handler-method’е — даёт int как результат всего with-блока.
  • i имеет тип int (never из body совместим с int из interrupt).

Невалидные альтернативы:

  • let h Fail[Error] = ... — compile error: Fail[Error] не type-position. Нужно let h Effect[Fail[Error]] = ....
  • with Effect[Fail[Error]] = h { ... } — compile error: Effect[Fail[Error]] не effect-position. Нужно with Fail[Error] = h { ... }.

10. Тип with-блока

let r = with Db = h { body }

Тип r определяется так:

  • T_body — тип финального выражения body.
  • Для каждого handler-method’а, который может завершиться без interrupt (т.е. через return v или финальное выражение): тип v должен быть совместим с типом, ожидаемым caller’ом операции (т.е. с return-типом операции в decl).
  • Для каждого handler-method’а, который может завершиться с interrupt v: тип v должен быть совместим с T_body.
  • Тип r = T_body.

Несовпадения — compile error:

let r = with Fail[E] = effect Fail[E] {
    fail(err) => interrupt "fail"           // handler даёт str
} {
    fetch_user_id()?                          // body даёт int
}
// COMPILE ERROR: handler interrupt type str != body type int

11. Параметры handler-method’а

Имена параметров handler-method’а биндят аргументы операции по позиции. Типы выводятся из effect-декларации — писать их в handler-литерале не обязательно (закрывает Q-handler-method-param-inference):

type Db effect {
    query(q Sql) Fail[DbError] -> []DbRow
}

effect Db {
    query(q) => real_query(q)                // q: Sql выводится из decl
}

// Явные типы тоже разрешены (для документации):
effect Db {
    query(q Sql) => real_query(q)            // OK, но избыточно
}

Алгоритм компиляции/интерпретации эффектов

Это тех-задание для имплементатора. Пошагово описывает что делает компилятор и runtime для каждой конструкции эффекта. Без этого раздела любая независимая имплементация выберет своё поведение и сломает совместимость.

При парсинге type Foo effect { ops }

  1. Парсер регистрирует тип Foo как effect-тип.
  2. Каждая op(params) effects? -> R в теле — сигнатура операции. Сохраняется в symbol-table эффекта Foo: имя, типы параметров, row эффектов внутри (опц.), return-тип.

При парсинге effect Foo { handler-methods }

  1. Парсер ищет Foo в symbol-table — должен быть effect-тип. Если protocol или другой тип — compile error.
  2. Каждый handler-method name(params) body сопоставляется с операцией Foo.name. Имена операций должны точно совпадать.
  3. Каждая операция эффекта обязана иметь handler-method (full coverage). Иначе compile error «handler missing operation name».
  4. Параметры handler-method’а биндятся по позиции к параметрам декларации операции; типы инферируются.
  5. Возвращается значение типа Effect[Foo].

При парсинге with EffectName = handler-expr { body }

  1. EffectName ищется в symbol-table — должен быть effect-тип.
  2. handler-expr должен иметь тип Effect[EffectName]. Иначе compile error.
  3. Тип body определяется по правилам выше (раздел «Тип with-блока»).
  4. with X = h1, Y = h2 { body } равно вложенным with’ам: with X = h1 { with Y = h2 { body } }.

При вызове операции EffectName.op(args)

  1. Type checker:
    • EffectName существует и это effect.
    • EffectName присутствует в effect-row enclosing-функции (или активен в текущем with-скоупе через inference, D28).
    • Типы args совместимы с декларацией операции.
  2. Runtime (interpreter / codegen):
    • Ищет в handler-стеке handler с тегом EffectName. Стек просматривается сверху вниз, берётся первый найденный.
    • Если не найден — runtime panic «no handler for effect EffectName».
    • Найденный handler — значение типа Effect[EffectName]. Из него извлекается handler-method op.
    • Управление передаётся в handler-method с биндингом параметров.
    • Continuation сохраняется (или, в (II) tail-only, не сохраняется — см. ниже).

При завершении handler-method’а

В семантике (II) tail-only — текущая принятая семантика Nova:

  • Handler-method это обычный блок. Finalize:
    • return v или финальное выражение — handler-method заканчивается нормально. Значение v передаётся в caller операции через стандартный return-механизм (тот же что для обычных функций). Continuation = «остаток caller’а после операции» — продолжается обычным flow-of-execution.
    • interrupt v — handler-method заканчивается аномально. Значение v становится результатом всего with-блока. Continuation не запускается (никакой возврат в caller операции).

Технически в (II):

  • Continuation не нужно сохранять как объект — она это просто «продолжение текущего call-stack’а после операции».
  • return v ⇒ обычный возврат из handler-method-fn, значение становится результатом операции для caller’а.
  • interrupt v ⇒ исключение-подобный escape: runtime разворачивает стек до границы текущего with-блока, делает v его результатом.

Это позволяет реализовать эффекты без специального fiber-runtime’а: обычный stack, обычные вызовы функций, исключение-подобное interrupt. Цена — нельзя писать код после возврата continuation в handler-method’е (нет resume в полной семантике).

В полной семантике (Koka, OCaml 5) continuation сохраняется как first-class объект, может вызываться явно. Это требует stack-снимка (corosensei в OCaml 5) или CPS-преобразования (Koka). Nova не идёт по этому пути — выбираем (II) ради простоты понимания и реализации. Если когда-нибудь потребуется multi-step или multi-shot resumption — это будет отдельный D-блок, отложен под Q-multishot-resume.

При прямом вызове h.op(args)

  1. h — значение типа Effect[E].
  2. op ищется среди handler-method’ов h. Если нет — compile error.
  3. Handler-method вызывается без push’а handler’а в with-стек.
  4. Continuation для этого вызова — обычный return (не возобновляет что-то снаружи h.op). interrupt v внутри прерывает h.op, возвращая v как результат именно этого вызова.

Lifetime handler-стека

  • При входе в with X = h { body } — push (X, h) на стек.
  • При выходе из body (любым способом — нормально, через interrupt, через panic) — pop стека.
  • Handler-стек локален текущему fiber’у/потоку. В bootstrap’е fiber один — стек глобальный.

Почему

  1. Закрытие зияющего пробела в спеке. До D61 семантика resume, тип Effect[E], поведение «без resume», запрет для never-операций — фактически использовались в коде, но не были формализованы. Любой имплементатор должен был догадываться. Теперь — пошаговый алгоритм, не требующий гипотез.

  2. Семантика «как обычный return» снижает порог входа. Программист, видящий handler-литерал впервые, должен понимать его за 30 секунд. query(q) => real_query(q) — «возвращает значение для query», как обычная функция. interrupt — единственный новый keyword, используется редко, его легко выучить отдельно.

  3. (II) tail-only достаточна для backend-кода. Реальные handler’ы (Fail, Db, Logger, Time, Random, Cache) укладываются в две формы — return v или interrupt v в tail-position. Полная resume-семантика с кодом-после-resume используется в backtracking и sampling-задачах, которые в Nova-целевой нише редкость.

  4. Раздельные effect / protocol — семантически разные контракты (статический dispatch vs lookup в with-стеке). Один keyword для обоих создавал ложное ощущение взаимозаменяемости.

  5. Effect[E] first-class — нужен для handler-декораторов (orm_decorators.nv), которые выражают audit / soft-delete / replica-routing как обычные функции. Без first-class handler’ов это невозможно сделать без AOP/reflection.

  6. Прямой h.op(args) — sugar для частого паттерна, без него декораторы пишутся в 2 раза длиннее через вложенный with.

  7. interrupt отдельный keyword — однозначно сигнализирует «прервать continuation», не требует понимания что финальное выражение делает в зависимости от типа операции.

Что отвергнуто

  • Слово resume для нормального завершения — литературное, но пользователь без опыта алгебраических эффектов не поймёт. В (II) tail-only это обычное возвращение значения, поэтому слово return (или финальное выражение, как у обычной функции) передаёт смысл точнее.

  • return в handler-method перегружен (значит «вернуть в caller операции», а в обычной функции «вернуть из самой функции»). Это технически правда, но семантика идентична для пользователя: «handler возвращает значение». Перегрузка минимальна.

  • Полная continuation-семантика (multi-step resume) — отложено. Цена реализации высока (stack-снимки или CPS), польза в backend-коде низка. Если потребуется — отдельный D-блок и keyword.

  • Multi-shot resume — отложено как Q-multishot-resume. Backend Nova не нуждается в backtracking-эффектах.

  • Effect[E] как Handler[E] или Impl[E]Effect[E] это стандарт литературы (Eff, Koka, Effekt) и наш choice после Plan 97 Ф.3 / D142 (см. amendment ниже + D87 amendment). Раньше использовался Handler[E] — снят clean-break’ом для симметрии с keyword’ом литерала.

  • Сохранение protocol для эффектов — раздельный keyword effect снимает двусмысленность со structural-protocol’ами.

  • Финальное выражение без keyword’а как «implicit interrupt» для never-операций — implicit поведение зависит от типа операции, AI-unfriendly. Явный interrupt для never и return/финальное выражение для остальных — однозначно.

Связь

  • D2 — концепция эффектов вместо keyword’ов.
  • D10 — «всё — эффект» как центральная ставка.
  • D11 — синтаксис with X = h { body }.
  • D18 — отменено в части «через protocol»; эффекты теперь через effect.
  • D25Fail[E] как эффект. D61 формализует, что Fail-handler использует interrupt (не resume).
  • D31 — handler-лямбда для одно-операционных эффектов. Сохраняется как сахар над effect X { ... }.
  • D40 — handler-method body имеет две формы (=> expr или { block }), как fn.
  • D53protocol остаётся для structural-интерфейсов. D61 расщепляет: protocol для типов значений, effect для эффектов.
  • 02-types.md → D55 — literal coercion применяется к параметрам операций как обычно.
  • 03-syntax.md → D23 — tail-position для return. D61 расширяет правило на return/interrupt в handler-method’ах.
  • 06-concurrency.md → D80 — handler scoping per-fiber. Семантика with X = h { body } локальна для текущего fiber’а; через spawn наследуется snapshot. D80 — runtime invariant поверх D61.

Цена

  1. Sweep по spec и examples. ~30+ файлов содержат protocol для эффектов (type Db effect { ... }) — переписать на effect. Handler-литералы (Db { query(q) => ... }) → effect Db { ... }. Fail-handler’ы и другие, которые не делали resume — добавить interrupt явно.

  2. Bootstrap-компилятор требует доработки. Сейчас (на момент D61):

    • effect keyword не парсится — пока используется protocol.
    • handler keyword не парсится — handler-литерал распознаётся эвристикой по Ident ( после {.
    • interrupt keyword не парсится — нет в lexer’е.
    • Effect[E] тип не понимается type checker’ом — это просто dynamic-typed value.
    • Прямой h.op(args) не реализован.
  3. Линтер interrupt для Fail-handler’ов — нужен, иначе старые (err) => -1 без interrupt’а будут проходить парсер, но семантически ломаются.

Эволюция

D61 — закрывающий блок системы эффектов. Закрывает Q-resume-semantics (в варианте (II) tail-only) и Q-handler-method-param-inference (в варианте (A) inference из protocol-сигнатуры). Также явно фиксирует расщепление protocol/effect (раньше намеренно объединённое в D53, но опыт показал — разные контракты, нужны разные keyword’ы).

Альтернативы которые рассматривались:

  • resume v (Koka-стандарт) — отвергнут, перегружает понятие для пользователя без опыта алгебраических эффектов.
  • effect Db { ... } для handler-литерала (двойное использование effect) — отвергнуто, путаница «тип/значение» через одно слово. REVERTED 2026-05-22, см. D142 — в Plan 97 принято обратное решение: keyword handler отменён, литерал записывается через effect X { ops } (clean break). Симметрия с объявлением type X effect { sigs } оказалась важнее изоляции «тип/значение» через отдельное слово; декларация vs литерал теперь различаются позицией (type ... префикс / выражение или let-инициализатор).
  • Handler[E]Effect[E] — отвергнуто, тавтология. REVERTED 2026-05-22, см. D142 — переименован в Effect[E, IRT] для симметрии с keyword’ом литерала effect. Тавтология не подтвердилась практикой: Effect[Db] читается как «значение-effect для эффекта Db» — то же отношение «тип/контекст», что []T (массив элементов типа T) или Option[T]. См. D87 для обновлённого определения.
  • (I) полная resume-семантика — отложена до Q-multishot-resume, backend-фокус Nova не требует.

Plan 97 amendment (2026-05-22) — handler keyword retired

Pre-D142 status: keyword handler парсился для handler-литерала (handler Db { query(q) => ... }), тип значения Handler[E, IRT].

D142 (post-Plan 97 Ф.3): keyword handler снят. Литерал записывается через тот же keyword effect, что и объявление, с дисамбигуацией по позиции:

// Объявление (как было)
type Db effect {
    query(q Sql) Fail[DbError] -> []DbRow
}

// Литерал (изменилось: handler → effect)
let pg = effect Db {
    query(q) => real_query(q)
}

// Тип значения (переименован Handler → Effect)
fn run(h Effect[Db]) -> () => with Db = h { ... }

Парсер различает декларацию и литерал по leading-keyword’у type: type X effect { ... } — declaration, effect X { ... } (без type) — literal. То же правило, что для protocol (см. D53 + D142

Clean break — миграция через sweep одной CL’ой (nova_tests/**, std/**, examples/**, spec/**). Backwards-compat не сохраняется.


D62. Прагматичная семантика эффектов: прямые в сигнатуре, Fail strict, Async ambient, правило effect/protocol

Что

Финальная ревизия философии эффектов после большой дискуссии о транзитивности, Async, Mut, и правиле выбора effect/protocol. Закрывающий блок этой темы.

Четыре связанных решения:

  1. Прямые эффекты в сигнатуре, не транзитивные. Функция объявляет только те эффекты, чьи операции она использует сама, не через вложенные вызовы.
  2. Fail strict. Эффект Fail[E] обязателен в сигнатуре везде, где может произойти throw — прямой throw e или expr? (который desugar’ится в throw). Транзитивный throw через границы вызовов тоже требует Fail в сигнатуре caller’а. Это исключение из правила «прямые эффекты».
  3. Async — ambient capability. Не пишется в сигнатурах, не является частью type system’ы. Fiber-runtime — реализационный механизм под капотом.
  4. Правило выбора effect/protocol для программиста — два вопроса. Сознательный выбор; compile-time enforcement = последствие.
  5. Mut[T] убран из стандартного набора эффектов. Реальные use-case’ы покрываются специализированными эффектами или локальными let mut.

Это большая ревизия философии. Ослабляется R5.2 «сигнатура = полное описание»: теперь сигнатура показывает только прямые эффекты

  • Fail транзитивно. Транзитивные эффекты других типов — лишь warning’ом подсвечиваются. R6 capability-режим ослабляется аналогично.

Правило 1. Прямые эффекты в сигнатуре

Что считается «прямым» использованием

Функция использует эффект прямо (и обязана его декларировать), если в её собственном теле:

  • Вызывается операция эффекта: Db.exec(...), Log.info(...).
  • Используется keyword-сахар, разворачивающийся в операцию эффекта: throw eFail[E].fail(e), expr? ⇒ throw на ошибке.

Функция использует эффект транзитивно (НЕ обязана декларировать, но есть warning), если:

  • Вызывает другую функцию, в чьей сигнатуре эффект объявлен.
  • Транзитивный throw через границы — исключение, см. правило 2.
fn save(u User) Db -> () {
    Db.exec(...)              // прямое использование Db — Db в сигнатуре
}

fn helper(u User) -> () {
    save(u)                    // транзитивное Db — warning, можно подавить
}

Семантика проверки

Type checker:

  • Прямой эффект не объявлен в сигнатуре → compile error
  • Транзитивный эффект не объявленwarning (suppressable)
  • Активный handler в runtime отсутствует на момент операции → runtime fail (panic)

Подавление warning’а

Программист может явно подавить warning:

#allow_transit(Db, Log)
fn helper(u User) -> () {
    save(u)         // save имеет Db Log, но helper не объявляет — без warning
}

Или через настройку Nova.toml для проекта:

[lints]
transit_effects = "off"        # disable warnings for whole project
# или
transit_effects = "error"      # treat as compile error (strict mode)

Программист контролирует уровень дисциплины для своего кода.

Правило 2. Fail strict — исключение из «прямых»

Fail[E] обязателен в сигнатуре функции всегда, когда внутри неё может произойти throw — прямой или транзитивный:

  • Прямой throw e или expr? в теле → Fail[E] в сигнатуре. Иначе compile error.
  • Транзитивный throw через вызов функции с Fail[E'] в её сигнатуре → Fail[E'] (или совместимая) в сигнатуре caller’а. Иначе compile error, не warning.
fn parse(s str) Fail[ParseError] -> int {
    if invalid(s) { throw ParseError.Bad }     // прямой throw — Fail обязан
    ...
}

fn pipeline(s str) Fail[ParseError] -> int {
    let n = parse(s)?                          // ? = throw — Fail обязан
    n
}

fn caller(s str) Fail[ParseError] -> int {     // ОБЯЗАН Fail (transit)
    pipeline(s) + 1
}

fn caller(s str) -> int {                       // COMPILE ERROR
    pipeline(s) + 1                             // pipeline может throw, не объявлено
}

Почему Fail — исключение

Throw это изменение control-flow, не side-effect. Программист обязан знать что вызов может «не вернуться нормально», иначе происходят bugs типа Java RuntimeException — невидимые crash’и. Это центральный аргумент checked exceptions из Java и Result<T,E> из Rust.

В Nova Fail[E] — типизированная версия checked-throw. Транзитивность сохраняется, чтобы caller всегда знал «может бросить — обработай или объяви».

Остальные эффекты (Db, Log, Time, …) — не control-flow, а side-effects. Они меняют мир, но не ломают возврат значения. Программист может позволить себе их не отслеживать транзитивно.

Альтернатива через with

Если caller хочет обработать throw локально и не объявлять Fail:

fn caller(s str) -> int {
    with Fail[ParseError] = |e| interrupt 0 {
        pipeline(s) + 1
    }
}

Handler ловит throw, дает дефолт. caller возвращает int, без Fail в сигнатуре.

Правило 3. Async — ambient capability

Async не является эффектом в Nova. Не пишется в сигнатурах, не является частью type system’ы.

fn fetch(url str) Net -> Response {        // НЕТ Async
    Net.get(url)
}

fn double(x int) -> int => x * 2           // тоже без Async

Под капотом — fiber-based scheduler. Функции могут suspend на yield-point’ах (network, sleep, channel.recv, async-Db). Это деталь реализации, не контракт типа.

Цвета функции нет — нет деления sync/async. Программист пишет код, fiber-runtime сам решает где можно вытесняться.

spawn, parallel for, supervised, with_timeout, race — остаются как runtime-конструкции (keyword’и или библиотечные функции), не как эффекты:

// Гомогенный fan-out — массив результатов через parallel for.
fn fetch_dashboard(uid int) Net Fail -> Dashboard {
    let users_and_posts = parallel for kind in ["users", "posts"] {
        fetch_section(uid, kind)
    }
    Dashboard.ok(users_and_posts)
}

// Гетерогенная параллельность — mut-захваты в supervised.
fn handle_request(req Request) Net Db -> Response {
    let mut users = []
    let mut posts = []
    supervised {
        spawn { users = fetch_users() }     // spawn — fire-and-forget statement
        spawn { posts = fetch_posts() }
    }
    Response.ok(users, posts)
}

spawn body сам по себе возвращает unit — не результат body. Результат — только через прямой вызов (async прозрачный), parallel for (массив) или mut-захваты (см. D50 п. 2).

Почему так

В backend-коде Nova почти каждая нетривиальная функция async — ходит в Db, Net, sleep’ит. Если Async в сигнатуре — он там везде. Информативность нулевая. Это шум.

Решение: убрать Async из типов. Программист не пишет, не выводит, не помнит. Fiber-runtime просто работает.

Прецедент: Go (горутины могут вытесняться где угодно, нет async- keyword’а), Erlang/Elixir (то же). Async в типах остаётся в Rust (где async важен из-за no-runtime), C# (где async из-за callbacks), Koka (academic effects). В Nova не нужен.

Правило 4. effect vs protocol — критерий resource-capability

Формулировка

Эффект описывает resource-capability — нечто, что можно подменить handler’ом в скоупе. Suspension и runtime-механизмы — не resource, а ambient mechanic, общая для всех асинхронных операций; они НЕ эффекты.

Resource-capability — концептуальная единица, к которой имеет смысл question «может ли это быть подменено в тесте?». Если да — это effect (handler-substitution). Если нет — это либо runtime-mechanic (не существует в типах), либо обычный protocol на значении.

Применение к стандартным эффектам:

ЭффектResource-capability?Подменяется в тесте?
Timeclockfixed_ms(ms) ✓ — фиксированный момент; mut_clock(start_ms) ✓ — sleep продвигает виртуальное время
RandomRNGseeded(seed) ✓ — xoshiro256++ deterministic PRNG
Db/Net/Fsсоединение/socket/fdin-memory handler ✓
Memalloc countermock-counter (для leak-тестов) ✓
Detachbackground supervisorSyncDetach
BlockingOS-thread poolmock ✓
Asyncfiber schedulerне подменяется (runtime mechanic) — НЕ effect

Источник test-handler’ов: std/testing/handlers.nv экспортирует seeded(seed u64) -> Effect[Random] (xoshiro256++ — tier с Go math/rand v2 PCG и Rust rand ChaCha8), fixed_ms(ms u64) -> Effect[Time], mut_clock(start_ms u64) -> Effect[Time]. Production-handler’ы (secure() CSPRNG, system_clock() realtime) — отдельный план (требуют runtime hooks: BCryptGenRandom/getrandom + libuv).

Decision flow для программиста

В Nova два разных способа описать «что-то с операциями»:

  • «Как делать что-то» — функция объявляет, что ей нужны такие-то операции, а какая реализация будет под ними — решает вызывающий код через with-блок (например, для прода — Postgres, для теста — in-memory). Это эффект, объявляется через type X effect { ... }.
  • «Что умеет значение» — реализация жёстко привязана к типу: int хешируется так-то, str — так-то, и менять это нельзя. Это протокол, объявляется через type X protocol { ... }.

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

Особый случай — runtime mechanic (Async/fiber scheduler, GC/region) — в типах не объявляется ни как effect, ни как protocol. См. Async, Mem/Trace instrumental эффекты в D26.

Decision matrix — канонические случаи

Тип / контрактResource?Continuation?РешениеWhy
Структурные protocols (значения)
Hashableнет (у каждого значения свой hash)нетprotocolbound на T в HashMap[K Hashable, V]
Ordнетнетprotocolbound в priority queue, сортировке
Eqнетнетprotocolbound в множествах
Iter[T]нет (конкретный итератор)нетprotocolfor-in / collect через D58
From[T] / Into[T]нетнетprotocolconversion (D73)
TryFrom[T,E]нетнетprotocolfallible conversion (D77)
Resource-capabilities (effects)
Dbсоединение к БДнетeffectmock в тестах через with Db = ...
Netсокет/HTTP-клиентнетeffectrecorded responses
Fsфайловая системанетeffectvirtual-fs handler
Timeclockнетeffectfixed_ms(...) ✓ (uuid v7, jwt); mut_clock(...) ✓ (rate_limiter, retry, cron — advance via sleep)
RandomRNGнетeffectseeded(...) ✓ — xoshiro256++ (uuid v4, ulid, snowflake, bcrypt)
Loglogger sinkнетeffectcapture-log в тестах
Tracedistributed tracerнетeffectв-memory trace
Iostdout/stderrнетeffectmock-stdout
Cache[K,V]кэш-провайдернетeffectin-memory mock
Authn/Authzidentity / capabilityнетeffectfixed-user в тестах
Idempotencydedup-storeнетeffectin-memory mock
Continuation-effects
Fail[E]error reporterда (throw → never)effectодин на язык, особый
Resource + instrumental
Memalloc counterнетeffect (instrumental)observability, ambient (D26)
Не существует в типах
Asyncfiber schedulerruntime mechanicsuspension ambient (D14/D62)
GC / regionmemory allocatorruntime mechanicimplicit (D6)

Кейсы где границы нечёткие

  • Logger как protocol: возможно, если используется через fn f(log Logger) parameter passing без mock. Но 99% случаев — effect (тесты подменяют). Default — effect.

  • Comparable vs Ord effect: Ord всегда protocol (bound). Если нужно «глобальный compare-handler в тесте» — это очень редкий use-case, лучше через named-fn-параметр.

  • Cache[K,V]: effect, потому что нужен mock в тестах (бесплатный with Cache = noop_cache). Если cache — value-handle (как Channel), то protocol; но обычно — handler-driven.

Аналогия со статическим классом

effect — это как статический класс с методами в C#/Java (Math.sqrt, Math.abs): нет инстансов, методы вызываются через имя (Db.query(...)). В отличие от обычного статического класса, у effect:

  • Реализация подменяется через with (статический класс не подменяется без рефлексии).
  • Operations могут захватывать continuation через throw / interrupt.

Если эти два свойства не нужны — это просто protocol на инстансе, не effect.

Compile-time enforcement = последствие

Type checker ловит несоответствие:

  • Тип объявлен через effect — используется в effect-position сигнатуры (fn f() Db -> ...), operations через имя X.op(...).
  • Тип объявлен через protocol — используется в позиции значения (параметр, поле, generic-bound), operations через инстанс x.op(...).

Смешение — compile error. Качество ошибок:

error: `Db` is an effect, not a protocol-bound
  in fn f[T Db](x T)
                ^^ effect cannot appear as type-bound
  hint: use effect-position instead:
        fn f(x T) Db -> ...

Это gatekeeper, ловит ошибки выбора; не диктует, какой выбор делать.

Правило 5. Mut[T] убран из стандартного набора

Mut[T] как generic эффект не существует в стандартной библиотеке Nova. Реальные сценарии mut-state покрываются:

  • Локальные let mut x — обычная mutable переменная, без эффекта.
  • Глобальное мутабельное состояние — через специализированные effect’ы (Counter, Cache, IdGen, etc.) с понятными именами и operations.
  • Атомарные счётчики, mutex’ыAtomic[T], Mutex[T] как тип-значения, не эффекты.

Каждый раз когда возникает соблазн «нужен Mut[T]», есть лучшая альтернатива: дать состоянию имя через специализированный эффект.

Если когда-то понадобится истинно generic Mut[T] — добавится отдельным D-блоком. На данный момент — не нужен.

Что меняется в R-главах

R5.2 «Сигнатура = полное описание»

Было: «по сигнатуре функции LLM/человек знает все побочные действия».

Стало: «сигнатура показывает прямые эффекты функции + Fail транзитивно. Side-effects через вложенные вызовы транзитивно warning’ом подсвечиваются — программист обязан знать, но не обязан писать».

Это сознательное ослабление ради компактности сигнатур в реальном backend-коде. Полная карта эффектов — расчётный артефакт, не часть spec’а.

R5.6 «Self-describing API»

Было: «по сигнатурам модуля видна полная карта эффектов».

Стало: «по сигнатурам видна карта прямых эффектов + полный throw-граф через Fail». IDE/линтер дают полную транзитивную карту по запросу.

R6 «Capability-режим»

Было: «функция без Net в сигнатуре физически не может ходить в сеть».

Стало: «функция без Net в сигнатуре прямо ходить в сеть не может; через вложенные вызовы — может, если их сигнатуры это допускают». Реальная capability-sandbox реализуется на closure- границах (декларация fn() -> T для callback’а гарантирует что callback ничего не делает) или через явный whitelist эффектов проекта (Nova.toml). Compile-time гарантия не транзитивная.

R7 «Async — эффект, не вирус»

Было: «Async — обычный эффект в сигнатуре. Без Future в типе.»

Стало: «Async — невидимая инфраструктура. Не часть типа. Цвета функции нет. Fiber-runtime под капотом. Программист не пишет, не видит, не помнит». Глава переименована в «Fiber runtime — прозрачный async».

R3 «Детерминированный режим тестирования»

Было: «любую программу можно запустить полностью детерминированно, если все эффекты заменены».

Стало: «программу можно запустить детерминированно, заменив все используемые эффекты. IDE подсказывает какие эффекты вовлечены по транзитивному графу. Compile-time гарантия только для прямых».

Что НЕ меняется

  • Грамматика effect-row в сигнатуре — без изменений.
  • D11 with-синтаксис — без изменений.
  • D25 Fail/throw/? — без изменений семантики, только подтверждается что Fail транзитивен.
  • D31 handler-литералы — без изменений.
  • D61 effect/handler keywords, interrupt, Effect[E] — без изменений. D62 это философское уточнение, не синтаксическое.

Стандартный набор эффектов (после D62)

| Эффект     | Что описывает                         |
|------------|---------------------------------------|
| Fail[E]    | Контракт для перехвата и обработки ошибки типа E |
| Io         | stdin/stdout/stderr                   |
| Fs         | Файловая система                      |
| Net        | Сетевые запросы                       |
| Db         | Базы данных                           |
| Time       | Часы, таймеры, задержки               |
| Random     | RNG                                   |
| Log        | Структурированный лог                 |
| Trace      | Распределённая трассировка            |
| Ask[T]     | Чтение из контекста (Reader)          |
| Alloc[R]   | Аллокация в регионе R                 |

Убраны: Async (ambient), Mut (специализированные эффекты вместо), Par (runtime-keyword, не эффект).

Почему

  1. Прагматизм vs дидактика. Полная транзитивность даёт максимально честные сигнатуры, но в реальном backend-коде эффект-row растёт до 8-10 имён, что тяжело читать. Прямые эффекты + Fail strict — баланс.

  2. AI-first сохраняется частично. LLM по сигнатуре всё ещё знает прямое использование функции и полную throw-картину. Транзитивные side-effects через помощь IDE — не трагедия для AI, который и так читает несколько уровней.

  3. Async как ambient — единственный разумный выбор. В backend-коде он везде. Если он эффект — он шум. Если ambient — программисту не надо думать. Прецедент: Go.

  4. Mut[T] не нужен. Каждый раз когда возникает идея «mut-cell» — правильнее дать ей имя. Generic Mut[T] провоцирует анти-паттерн «безымянное shared state».

  5. effect/protocol правило через подмену. Sniff-test «подменяю ли через with в тестах» — практически проверяемый критерий, не философская абстракция.

  6. R5.2 ослабление обоснованно. Чистая транзитивность в эффектах не существует ни в одном мейнстрим-языке. Nova остаётся впереди других языков (Java, Go, Python) в плане видимости throw + прямых эффектов, но не пытается решить «полную карту через типы», что неподъёмно для production-кода.

Что отвергнуто

  • Полная транзитивность всех эффектов — обоснованно для революционной заявки, но громоздко в реальном коде. Принят компромисс «прямые + Fail strict».
  • ..E row-tail polymorphism — не нужен с прямыми эффектами. Closure-параметры не пробрасывают эффекты caller’у.
  • Async как явный эффект — везде в backend, шум.
  • Mut[T] как generic эффект — анти-паттерн «безымянное shared state», предпочтительны специализированные.
  • Полное удаление эффектов из сигнатур (Java/Python style) — теряется проверка throw, теряется handler-substitution-видимость. Не идём так далеко.
  • Compile-time гарантия capability через все границы — только на closure-границах с явной декларацией. Полная транзитивная capability-sandbox не дается типами.

Связь

  • D2, D3 — синтаксис effect-row, без изменений.
  • D11with синтаксис.
  • D25 — Fail/throw/?, теперь явно strict-транзитивный.
  • D28 — effect inference, теперь только для прямых эффектов в private. Транзитивных нет.
  • D61 — effect/handler keywords, Effect[E], interrupt. D62 это философское уточнение D61.
  • 01-philosophy.md → D10 — AI-first пересмотрен в R-главах.
  • revolutionary.md — R2/R3/R5.2/R5.6/R6/R7 обновлены.

Цена

  1. Sweep по spec и examples — убрать Async из всех сигнатур (~30+ мест). Перепроверить что в сигнатурах только прямые эффекты (большинство уже так — реальные функции используют свои эффекты напрямую).
  2. Bootstrap-компилятор: warning для транзитивных эффектов, strict для Fail. Атрибут #allow_transit в парсере (опционально).
  3. R-главы переписать — революционная заявка ослабляется. Это важно для маркетинга/документации, README.

Эволюция

D62 финализирует длительную дискуссию о транзитивности эффектов. Изначально (до D62) Nova была транзитивной по всем эффектам — что обоснованно для «AI-first язык где сигнатура говорит правду». Опыт с реальными примерами (effect-density/) показал что сигнатуры накапливают 8-10 эффектов, нечитаемые. Обсуждалось row polymorphism (..E), но это сложно в type checker’е. Финальное решение: прямые + Fail strict — баланс компактности и проверки control-flow.

Async всегда был спорным эффектом — везде в backend-коде, шум. D62 переводит его в ambient capability. Глава R7 переписывается: «не эффект-не-вирус», а «вообще не часть типа».

Mut[T] упоминался в R2 списке эффектов, но не имел реальных use-case’ов. Каждый раз когда возникал — оказывался лучше через специализированный эффект. D62 убирает его.


D65. Полная семантика Fail: гибрид Fail[E] / Fail, lookup, prelude RuntimeError и Error

Что

Закрывающий блок по теме обработки ошибок. Объединяет четыре связанных решения:

  1. Гибридная параметризация FailFail[E] типизированный (рекомендуется для public API) и Fail без параметра как сахар для Fail[any] (catch-all, quick-and-dirty).
  2. Subtype-aware lookup при throw: точный тип E → Fail (any) → runtime panic. Match по конкретным вариантам sum — внутри handler’а через обычный match.
  3. Re-throw внутри handler’а через throw expr — ищется outer handler в стеке.
  4. Prelude-типы для runtime-ошибок: sum-тип RuntimeError с фиксированным набором вариантов + record Error { msg } для пользовательских ошибок с сообщением.

D65 заменяет ранее существовавший unit-маркер type Error в prelude (D26) на полноценный record. Также формализует лукап-правило handler’ов для Fail, которое раньше было implicit.

Правило 1. Гибридная параметризация: Fail[E] или FailFail[any]

// Типизированный — рекомендуется для public API
fn parse(s str) Fail[ParseError] -> int {
    if invalid(s) { throw ParseError.Bad }
}

// Сахар — Fail ≡ Fail[any], catch-all
fn quick_helper(s str) Fail -> int {
    if bad { throw "raw string error" }
}

// Generic с явным [E] параметром — типизация через generics
fn retry[T, E](attempts int, body fn() Fail[E] -> T) Time Fail[E] -> T

// Caller:
retry(3, || parse("..."))
//              ↑ возвращает Fail[ParseError] → E = ParseError
//                retry имеет signature Fail[ParseError]

Семантика — две формы:

ФормаСемантикаUse-case
Fail[E]typed — точный тип ошибкиpublic API, библиотечный код
FailFail[any]catch-all — сахар над erasure-формой; ловит throw любого типаprivate fn, quick scripts, top-level supervisors

Fail без параметра — синтаксический сахар над Fail[any]. Одна форма с одной семантикой; никакой placeholder-инференс E не делается. Если программисту нужна типизация — пишет Fail[E] явно (или использует generic-параметр [E] как в retry выше).

Convention (рекомендация, частично enforce’ится линтером):

КонтекстФормаЛинт
export (public API)Fail[E] с конкретным типомwarning если Fail без E
Library, переиспользуемый кодFail[E]warning
Internal/private helperFail[E] или Failok
Quick-and-dirty / scripts / тестыFailok
Generic в retry, transactionFail[E] через [E]ok
Catch-all logger / supervisorFail или Fail[any]ok (намеренный паттерн)

Линтер может предупреждать «public-fn использует Fail без параметра» — suppressable через настройку проекта.

Зачем Fail без параметра — catch-all use-case

Fail (sugar над Fail[any]) — не косметика. Это отдельная семантика catch-all handler’а, без которой не выражаются три canonical паттерна:

1. Top-level supervisor:

fn main() Io -> () {
    with Fail = |e| Log.error("uncaught: ${e}") {
        run_app()
    }
}

run_app() может бросать любые Fail[E1], Fail[E2], … — все ловятся одним handler’ом. Без Fail (any) пришлось бы перечислять все типы ошибок, что невозможно для composable systems.

2. Untrusted plugin / user code:

fn run_plugin(p Plugin) -> Result[(), str] {
    with Fail = |e| interrupt Err(str.from(e)) {
        Ok(p.execute())
    }
}

Plugin может бросать что угодно (типы из его собственного кода, неизвестные caller’у). Catch-all позволяет sandboxить.

3. Quick scripts / REPL:

fn quick_check() Fail -> int {
    let n = parse(input)?     // Fail[ParseError]
    let v = lookup(n)?        // Fail[LookupError]
    v + 1
}

В quick-and-dirty коде программист не хочет писать Fail[ParseError | LookupError]Fail достаточно.

Safety сохранена

Эффект Fail остаётся видимым в сигнатуре — главное свойство системы эффектов не нарушено: caller знает, что функция может бросить. Тип ошибки не указан — это compile-time рекомендация (линт export-fail-untyped), не нарушение effect-safety.

Trade-off

ФормаUse-caseCompile-time check
Fail[E]typed business errorsexhaustive match по E
FailFail[any]catch-all / supervisor / scriptsruntime is-check на handler-стороне

Сознательный trade-off: catch-all теряет exhaustiveness в match (handler получает значение типа any, программист использует is-проверки или str.from(e)), взамен покрывает три use-case’а выше.

Прецеденты

  • Java unchecked exceptions (RuntimeException) — catch-all без typed checked exceptions. Известная проблема: catch-all невидим в сигнатуре. Nova решает: видим, но не типизирован.
  • Go error interface — единственный тип ошибки, runtime-typed. Прямой аналог Nova Fail[any].
  • Rust Box<dyn Error> — explicit erasure для top-level error handling. Тоже прямой аналог.

Правило 2. Lookup при throw expr

throw expr это keyword-сахар над операцией эффекта Fail[E].fail(expr), где E = type-of(expr). Runtime ищет handler в стеке:

  1. Точное совпадение — handler Fail[E] где E совпадает с типом значения. Если найден — вызывается.
  2. Catch-all — handler Fail (≡ Fail[any]). Если найден — вызывается.
  3. Runtime panic «no handler for Fail» — если ни один не найден.

Lookup идёт сверху вниз стека (свежие handler’ы первыми, как для любого эффекта).

Match по sum-вариантам — внутри handler’а

Для перехвата конкретного варианта sum-типа использоваться handler один на тип + match внутри:

type RuntimeError | DivByZero | Overflow | IndexOutOfBounds

fn risky() Fail[RuntimeError] -> int {
    throw RuntimeError.DivByZero          // тип значения: RuntimeError
}

with Fail[RuntimeError] = |err| match err {
    DivByZero => interrupt 0
    Overflow  => interrupt MAX_INT
    _         => interrupt -1
} {
    risky()
}

Тип брошенного значения — RuntimeError, не DivByZero (DivByZero это sum-вариант, не отдельный тип). Поэтому Fail[DivByZero] не существует для этого случая. Один handler Fail[RuntimeError], разбор внутри.

Subtype-aware lookup НЕ делается

Lookup проверяет точное совпадение типа, не subtype-relations. Fail[RuntimeError] не ловит автоматически Fail[DivByZero] (если DivByZero отдельный тип) и наоборот. Если нужна гибкость — программист явно использует Fail (any) как catch-all.

Это сохраняет локальное reasoning: программист видит handler Fail[X] и знает что он перехватывает только throw expr где type-of(expr) == X.

Правило 3. Re-throw для частичной обработки

throw expr внутри handler-method’а — это обычная операция эффекта Fail. Runtime ищет handler в стеке, минуя текущий handler-frame (текущий обрабатывает throw, не может ловить сам себя). Если outer есть — он перехватит. Если нет — runtime panic.

with Fail[RuntimeError] = |err| interrupt log_and_default(err) {
    with Fail[RuntimeError] = |err| match err {
        DivByZero => interrupt 0       // обработали локально
        other     => throw other        // пробросили дальше — найдёт outer
    } {
        risky()
    }
}

Это позволяет:

  • Обрабатывать подмножество sum-вариантов локально.
  • Пропускать остальные дальше по стеку.
  • Композиция handler’ов через nested-with.

Правило 4. Prelude-типы для ошибок

RuntimeError — sum-тип runtime-сбоев

// в prelude (D26)
type RuntimeError
    | DivByZero
    | Overflow
    | IndexOutOfBounds { index int, length int }
    | TypeMismatch(str)
    | AssertFailed(str)
    | NoHandler(str)

Встроенные runtime-операции бросают конкретные варианты:

ОперацияБросает
a / b (b == 0)RuntimeError.DivByZero
arr[i] (i out of bounds)RuntimeError.IndexOutOfBounds { index: i, length: arr.len }
(x as Type) (cast fail)RuntimeError.TypeMismatch("expected ..., got ...")
assert(cond) (false)RuntimeError.AssertFailed("...")
Db.query(...) (no handler)RuntimeError.NoHandler("Db")

Переполнение знаковой целочисленной арифметики int (a + b, a - b, a * b за границами int.MIN..int.MAX) — panic, не Fail (Plan 33.8 Ф.1.1, решение 2026-05-21). Не ловится в коде; как StackOverflow/OutOfMemory. Причина: переполнение — баг программы, а не ожидаемая ошибка; делать каждую арифметическую операцию эффектной (Fail в сигнатуре) недопустимо эргономически. Sized-типы (u8/u16/u32/u64/i8/i16/i32) — иная семантика: wrap-around по модулю 2^N (см. Plan 33.7). Вариант RuntimeError.Overflow сохранён в типе для явных checked-арифметических API stdlib, но оператор + его НЕ бросает.

StackOverflow и OutOfMemory не входят в RuntimeError — они panic’и, не Fail. Не ловятся в коде. См. D13.

Error — record для пользовательских ошибок с сообщением

// в prelude (D26)
type Error {
    readonly msg str
}

fn Error.new(msg str) -> Error => { msg }

Quick-and-dirty замена throw "string":

fn validate(x int) Fail[Error] -> () {
    if x < 0 { throw Error.new("negative not allowed") }
}

Используется когда:

  • Программист не хочет придумывать typed sum.
  • Сообщение для лога/UI достаточно (не разбор по вариантам).

Альтернатива — типизированный sum для domain-логики:

type ValidationError | NegativeNotAllowed | TooLarge(int)

fn validate(x int) Fail[ValidationError] -> () {
    if x < 0 { throw ValidationError.NegativeNotAllowed }
}

Для production-API typed sum предпочтительнее (compile-time exhaustiveness в match).

Замена ранее существовавшего unit-маркера Error

В D26 (08-runtime.md) ранее был type Error как unit-тип-маркер для Fail без параметра. D65 заменяет его на record Error { msg str }, полезный для quick-and-dirty.

Правило 5. Транзитивность с гибридом — уточнение D62

D62 фиксирует «Fail strict транзитивен». С гибридом это уточняется:

Совместимость по подтипу

Caller declaredCallee может бросать
Fail[E]только Fail[E] (тот же тип) или ничего
Fail (any)Fail[E] любого E, Fail (any), throw любого значения

То есть Fail (any) поглощает любой Fail[E] — это естественно, any это top-type.

В обратную сторону — Fail[E] не покрывает Fail (any). Caller с Fail[E] не может вызывать функцию с Fail (any) без явной обёртки.

Несовместимость

Если callee имеет Fail[E'], а caller декларировал Fail[E] (E ≠ E’):

  • Compile error, не warning.
  • Программист обязан выбрать:
    1. Объявить Fail[E'] как дополнительный эффект (multi-Fail в row).
    2. Использовать Fail (any) — поглощает оба.
    3. Обернуть через .map_err(...)? для конверсии E’ → E.
    4. Локально поймать через with Fail[E'] = ... { ... } и не пробрасывать.

Multi-Fail в row синтаксически валиден:

fn process(s str) Fail[ParseError] Fail[RuntimeError] -> int {
    parse(s)?            // throws ParseError
    safe_div(n, 2)?      // throws RuntimeError
}

Две раздельные Fail-записи. Caller обязан установить два handler’а или один Fail (any).

Coercion через sum-variant — отложено

«Если E имеет однозначный конструктор для типа источника E', ? автоматически coerce’ит» — отложено как Q-fail-coercion. Сейчас требуется явный .map_err(...) или multi-Fail.

Что меняется по сравнению с D25

D25 (throw и параметризация Fail[E]) остаётся валиден в основной части. D65 уточняет:

  1. Fail без параметра — теперь явно сахар над Fail[any], не unit-маркер.
  2. Lookup-правило (точный тип → catch-all → panic) явно зафиксирован.
  3. Re-throw через throw err в handler’е явно описан.
  4. Prelude-типы RuntimeError и Error — новые, заменяют unit-маркер.

Раздел «Эволюция» D25 апдейтится с указанием на D65.

Почему

  1. Гибрид удобства и точности. Fail[E] для production даёт compile-time exhaustiveness и точный caller-knows-what-to-catch. Fail (any) для quick-and-dirty не заставляет придумывать тип. Один способ был бы крайностью.

  2. Простой lookup без subtype-magic. Точное совпадение типа — локально проверяемо. Match внутри handler’а покрывает sum-варианты. Не нужно расширять type system’у на subtype-aware lookup.

  3. Re-throw позволяет композицию handler’ов. Локальная обработка подмножества + проброс остальных — стандартный pattern, работает через standard effect mechanics.

  4. RuntimeError sum даёт типизированный set встроенных ошибок. Caller match’ит варианты, добавление новой ветки в RuntimeError ломает существующие caller’ы (через non-exhaustive match warning). Это фича — программист обновляется консистентно.

  5. Error record — низкоуровневый escape hatch. Не sum-тип (нечего match’ить, кроме msg), но удобный для логов и UI.

Что отвергнуто

  • throws E keyword (Java-style) — не нужен, единая запись Fail[E] единообразна с другими эффектами. Прецедент вводить второе имя для одного концепта (throwsFail) нарушает D40-style «один способ для одного случая».
  • Subtype-aware lookup (Fail[RuntimeError] ловит Fail[DivByZero] если DivByZero ⊆ RuntimeError) — отвергнуто. Match внутри handler’а достаточно. Subtype-aware расширил бы type system на sum-subtype-relations, цена/польза неудачное.
  • Auto-coercion ? через однозначный sum-variant — отложено как Q-fail-coercion. Сейчас явный .map_err(...).
  • Fail без параметра как отдельный эффект, не алиас на Fail[any] — отвергнуто. Лишняя сущность; алиас даёт ту же семантику.
  • Auto-inference Fail[RuntimeError] для функций использующих встроенные операции — отвергнуто. Программист пишет руками (для public — D62 strict; для private — D28 inference, который выводит на основе тела). Если в теле есть arr[i] или a/b, D28-inference добавляет Fail[RuntimeError] в инферированную сигнатуру, но программист может явно написать Fail (any) или Fail[CompositeError] если делает map_err.
  • throws SomeError | throw SomeError — путаница keyword’ов. В Nova throw это keyword (как return), Fail[E] это эффект-тип. Они на разных уровнях: throw — control-flow в теле, Fail[E] — декларация в сигнатуре.

Связь

  • D2, D3 — синтаксис effect-row.
  • D4? пробрасывание ошибки.
  • D86?? coalesce / fallback.
  • D11with синтаксис.
  • D25throw и Fail[E]. D65 уточняет Fail без параметра, lookup, re-throw.
  • D26 — prelude. Error и RuntimeError добавлены/обновлены.
  • D31 — handler-лямбда для одно-операционных эффектов. Работает для Fail (одна операция fail).
  • D53any как top-type через пустой protocol; основа для FailFail[any].
  • D54is для runtime-проверок типа в catch-all handler’е Fail (any).
  • D61 — effect/handler keywords, interrupt. D65 не меняет.
  • D62 — Fail strict. D65 уточняет совместимость типов при транзитивности.

Цена

  1. Sweep по spec и examples — заменить Fail (там где quick-and-dirty) на корректные формы:
    • transaction[T](body fn() Db Fail -> T) Fail -> T — generic параметр [T, E]: transaction[T, E](body fn() Db Fail[E] -> T) Db Fail[E] -> T
    • Конкретные функции (parse(s) Fail) — Fail[ParseError] или оставить Fail (any) для скрипт-кода.
    • Эталоны в spec (fn parse(s str) Fail -> int) — переписать с явным Fail[ParseError] для clarity.
  2. Bootstrap-компилятор:
    • Парсер уже принимает Fail без параметра (как имя эффекта).
    • Type checker нужно расширить на subtype-aware «Fail (any) поглощает Fail[E]».
    • Re-throw в handler’е работает через стандартную effect mechanics.
  3. Prelude в bootstrap’е: добавить RuntimeError sum и Error record. Заменить старый unit-маркер Error.

Эволюция

Fail без параметра существовал в D25 как сахар над Fail[Error], где Error был unit-маркером. Это работало, но Error без полей был бесполезен. D65 переопределяет:

  • Error теперь record { msg str } — полезный.
  • Fail без параметра теперь сахар над Fail[any] (universal).
  • Lookup с приоритетом «точный тип → catch-all → panic».

Дискуссия привела через несколько итераций:

  • Сначала рассматривался Fail strict-only (всегда явный тип). Отвергнуто — quick-and-dirty неудобно.
  • Потом Fail = Fail[RuntimeError] (фиксированный тип). Отвергнуто — ограничивает универсальность.
  • Финал: гибрид Fail (any) + Fail[E] typed.

RuntimeError как sum-тип был очевидным решением — встроенные операции имеют конечный набор runtime-сбоев, sum покрывает.

Error как record (не sum) — для случаев когда программист не хочет типизированный domain-sum, но хочет message. Это replacement старого unit-маркера.

Откат «трёх форм» (2026-05-07)

В одной из итераций рассматривалась трёхформенная семантика (Fail placeholder ≠ Fail[any] erasure), где Fail без параметра означал бы «inference placeholder — компилятор выводит конкретный E». Откатано к простой Fail ≡ Fail[any] по двум причинам:

  1. Различие наблюдаемо только при полном type-inference, которого bootstrap не реализует. В runtime/codegen «голый Fail» эрейзится через lookup как catch-all (Правило 2), что эквивалентно Fail[any]. Production-компилятор может реализовать placeholder-семантику через точную D28-инференс E, но это отдельное расширение, не часть базового D65.

  2. Catch-all use-case требует erasure-семантики. Программист пишет with Fail = |e| Log.error(e) { ... } чтобы поймать любой throw независимо от типа. Если бы Fail был placeholder (ждёт inference), у with Fail = handler не было бы контекста для inference — паттерн терял бы чёткость. С Fail ≡ Fail[any] семантика однозначна: handler принимает значение типа any, в теле — is-проверки или str.from(e) для message.

Реализация bootstrap’а (commit 284b2074) уже соответствует откатанной формулировке — добавляет голый Fail без E через D28-inference; дальше lookup эрейзит его как catch-all.


D63. forbid X { body } — capability sandbox

Что

Keyword-блок, запрещающий использование операций перечисленных эффектов внутри body. Реализуется на двух уровнях:

  1. Compile-time: для каждой функции, вызываемой в body, type checker проверяет, что её прямые эффекты не пересекаются с forbid-set. Иначе compile error.
  2. Runtime: при операции forbid-эффекта runtime ловит и fail’ится — даже если функция была пропущена compile-time проверкой (через D62 transit warning или handler-substitution).

forbid непреодолим: код в body не может выйти из sandbox через with X = .... Установка нового handler’а для forbid-эффекта внутри — compile error.

R6 (revolutionary.md) ссылается на D63 как на формализацию capability mode.

Capability sandbox: три механизма, разные цели

В Nova есть три инструмента для ограничения «что код может делать», часто их путают. Разница важна:

МеханизмЧто ограничиваетГде задаётсяЧто нарушение даёт
forbid X { body } (D63)использование эффектов из setвокруг блока кодаcompile error + runtime fail
realtime { body } (D64)suspension (приостановка fiber’а)вокруг блока кодаruntime panic
closure границы (D62 capture rules)какие handler’ы захватываютсяпри создании handler’аtype error если handler’а нет

Когда какой использовать:

  • forbid — когда нужно гарантировать «эта подсистема НЕ обращается к Net/Db/Fs» (sandbox для plugins, contract-функций, pure_view).
  • realtime — когда нужно гарантировать «здесь нельзя приостанавливаться» (real-time loops, ISR-like обработчики, hot paths). Async — runtime-факт, не эффект, поэтому forbid Async невозможен; realtime — отдельный inverse-маркер.
  • closure границы — автоматически: при создании handler-литерала компилятор проверяет, что захваченные handler’ы валидны в момент использования. Не вмешательство программиста.

Они не пересекаются по семантике — каждый закрывает свою категорию проверок:

fn pure_view(u User) -> str =>
    forbid Net, Db, Fs {           // нельзя side effects
        realtime nogc {            // нельзя suspension и аллокации
            format(u)
        }
    }

Композиция работает: forbid запрещает effect-вызовы, realtime дополнительно запрещает suspend-точки и (при nogc) аллокации. Программист выбирает один или оба в зависимости от того, что гарантировать.

Правило

forbid Net, Fs, Db { body }

Внутри body:

  • Прямой вызов операции Net.op(...), Fs.op(...), Db.op(...)compile error.
  • Вызов функции с Net/Fs/Db в прямой сигнатуре — compile error.
  • with Net = h { ... } (или Fs, Db) — compile error: «cannot install handler for forbid-effect».
  • Транзитивный вызов через функцию которая не объявила forbid-эффект, но вызывает что-то с ним — compile-time warning (по D62), runtime fail на момент операции.

Runtime барьер

Реализуется через специальный sentinel-frame в handler-стеке:

handler-стек (lookup сверху вниз):
  ┌────────────────────────┐
  │ FORBID(Net, Fs, Db)    │  ← sentinel, push'нут при входе в forbid
  │ Db = postgres_handler  │  ← старый, ниже
  │ ...                    │
  └────────────────────────┘

При операции forbid’ed эффекта (Db.query(...)):

  • Runtime ищет handler сверху вниз.
  • Видит FORBID(Db) первым — fail с «effect Db is forbidden in current scope».

Установка нового handler’а внутри запрещена

Если бы установка with Db = other { ... } внутри forbid Db { ... } была разрешена, новый handler оказался бы выше sentinel’а в стеке и lookup нашёл бы его раньше — sandbox обходится. Запрещаем установку compile-time:

forbid Db {
    with Db = mock_db {       // COMPILE ERROR
        ...
    }
}

Это делает sandbox непроницаемым.

Пример: плагин в sandbox

fn run_plugin(plugin Plugin) -> str {
    forbid Net, Fs, Db {
        plugin.invoke()       // compile-time + runtime гарантия
                              // что plugin не ходит в Net/Fs/Db
    }
}

Пример: детерминированное вычисление

fn compute_pure(input []u8) -> []u8 {
    forbid Time, Random, Io, Net, Fs, Db {
        process(input)        // гарантированно детерминировано
    }
}

Async нельзя forbid’ить

Async это не type-system эффект (D62), а ambient capability fiber-runtime’а.

forbid Async { ... }    // COMPILE ERROR: «Async is not a type-system
                        // effect, use `realtime { ... }` block instead»

Для запрета приостановки используется отдельный realtime { ... } блок (D64) — это runtime-конструкция, не часть type system’ы.

Запретить можно только effect-типы

forbid принимает только effect-типы (D62 правило effect/protocol):

forbid Hashable { ... }    // COMPILE ERROR: Hashable это protocol, не effect
forbid Net { ... }         // OK: Net это effect

Семантика Fail

Fail[E] — обычный effect, можно forbid:

forbid Fail[ParseError] { ... }     // запрет throw'а ParseError
forbid Fail { ... }                  // запрет любого throw (Fail any)

Если внутри есть throw expr который соответствует forbid’ed Fail — compile error. Runtime fail если транзитивно через несовместимую функцию.

Грамматика

forbid-block = 'forbid' effect-list block
effect-list  = type-ref { ',' type-ref }

type-ref это полная ссылка на effect-тип, включая generic-параметры: Fail[ParseError], Fail[E] (из generic-контекста).

Почему

  1. Capability sandbox без runtime-only решений. Java SecurityManager — runtime, не compile-time. Compile-time даёт feedback при разработке.
  2. Симметрия с with: with X = h { ... } устанавливает handler; forbid X { ... } запрещает. Pair-of-opposites.
  3. Прецедент Effekt language — capability tracking через тип, forbid через ограничение row.
  4. Использования: плагины, песочницы для AI-сгенерированного кода, детерминированные вычисления, тестирование «функция не делает X».

Что отвергнуто

  • Только compile-time forbid (без runtime барьера) — D62 ослабил R5.2 для прямых эффектов, поэтому compile-time не ловит транзитивные вызовы. Runtime барьер нужен для полной гарантии.
  • Только runtime forbid (без compile-time) — теряется immediate feedback в IDE при написании кода.
  • Soft forbid (warning вместо error) — sandbox должен быть гарантирован, не «вежливое предупреждение».
  • Forbid Async — Async не существует в типах (D62); realtime { ... } для запрета приостановки (D64).
  • Forbid non-effect-types (protocol, sum, record) — не имеет смысла; forbid это про эффекты-как-capabilities.

Связь

  • D11with синтаксис. forbid синтаксически близок.
  • D62 — effect/protocol правило, прямые эффекты.
  • D64realtime для async-запрета (отдельный механизм).
  • revolutionary.md → R6 — capability mode описан, D63 формализует.

Цена

  • Bootstrap-компилятор:
    • Lexer: keyword forbid.
    • Parser: forbid effect-list { body } блок.
    • AST: ExprKind::Forbid { effects, body }.
    • Interp: sentinel-frame в handler-стеке; runtime-проверка операций.
    • Type checker (опционально для bootstrap): compile-time валидация прямых эффектов callee’ев.
  • Спека: D63 + R6 ссылка на D63.

Реализация в bootstrap (2026-05-09, Plan 16 Ф.1-Ф.6)

Compile-time enforcement реализован в compiler-codegen/src/types/mod.rs через CapabilityCtx. Walk модуля проходит fn-bodies + test-bodies со state’ом forbidden_stack: Vec<HashSet<String>>. На входе/выходе из ExprKind::Forbid { effects, body } push/pop. На каждом call-site union forbidden-стека пересекается с callee.effects → R5.3 error.

Forbid-handler-ban (D63 §3473): ExprKind::With { bindings, body } проверяет, что устанавливаемые handler’ы не пересекаются с forbidden_union. Иначе error «cannot install handler for X inside forbid X block».

Pure-fn (callee.effects пустой) — всегда OK.

Транзитивные эффекты (callee → callee → effect) пока не trace’ятся (D62 говорит — warning). Закроется после полного effect-row inference (отдельный план).


D64. realtime { body } / blocking { body } — гарантия не-приостановки (RETRACTED by Plan 113)

⚠️ RETRACTED (Plan 113, 2026-05-29). Block-forms realtime { } и blocking { } удалены из языка. Логика и семантика переехали в D172 attribute-only model:

  • realtime { body } → extract в #realtime fn (callee guarantee)
  • blocking { body } → extract в #blocking fn (fn-level threadpool offload)

Атрибут #realtime на функции — сохранён (callee guarantee модель, D172). D64 retract’ирован как block-form spec.

История ниже сохранена для понимания эволюции.

Что (историческое, retracted)

Runtime-блок, гарантирующий что код внутри не приостанавливается на yield-point’ах fiber-runtime’а. Применяется для real-time-зон, hot loops, lock-критичного кода.

realtime это не эффект (Async убран из type system по D62), а runtime-конструкция. Семантика — fiber-runtime отказывается выполнять suspend-операции внутри realtime-блока, fail’ится при попытке.

Правило

realtime { body }

Внутри body:

  • Synchronous вычисления — OK (математика, локальные mut, локальные структуры на стеке).
  • Доступ к ambient handler’ам без suspend — OK (например, Log.info если log-handler не блокирующий).
  • Suspend-операции — runtime panic «cannot suspend in realtime block». Включает: Net.get(...), Fs.read(...), Db.query(...), Time.sleep(...), Channel.recv(...), любая операция, которая в fiber-runtime приводит к yield’у.

Compile-time

Type checker (production-компилятор) может частично ловить нарушения:

  • Вызовы функций с эффектами Net, Fs, Db, Time (известно что они suspend) — compile error.
  • Это не полная гарантия — пользовательский effect может suspend через свой handler. Runtime барьер всё равно нужен.

Runtime

Fiber-runtime устанавливает флаг при входе в realtime-блок. Каждая suspend-точка проверяет флаг — если активен, runtime panic.

Пример: hot loop без suspend

fn checksum(data []u8) -> int {
    realtime {
        let mut sum = 0
        for b in data { sum += b as int }
        sum
    }
}

Пример: lock-критичная секция

fn update_counter(counter mut Counter) {
    counter.lock()
    realtime {
        counter.value += 1     // не должно yield'нуть с захваченным lock'ом
    }
    counter.unlock()
}

Атрибут #realtime на функции

Sugar для функции целиком (атрибут-префикс # — см. D96):

#realtime
fn checksum(data []u8) -> int {
    let mut sum = 0
    for b in data { sum += b as int }
    sum
}

// эквивалентно:
fn checksum(data []u8) -> int {
    realtime {
        let mut sum = 0
        for b in data { sum += b as int }
        sum
    }
}

Что внутри запрещено

ОперацияЗапретПочему
Net.get(...)даnetwork roundtrip → suspend
Fs.read(...)даdisk I/O → suspend
Db.query(...)даnetwork → suspend
Time.sleep(d)даявный sleep → suspend
Time.now()нетобычно sync (timer read)
Random.next()нетsync RNG
Log.info(...)зависитесли handler не blocking — OK
Channel.recv()даблокирующий wait → suspend
Channel.send_nonblocking()нетnon-blocking — OK
spawn ...дасоздаёт fiber, нарушает «нет suspend»
Аллокация в managed heapзависитесли GC может paus’ить — да

Точный список — задача production-компилятора и runtime’а; D64 фиксирует принцип: «всё что может yield — запрещено».

Опционально — запрет аллокации

Для жёсткого real-time-mode’а можно запретить аллокацию в managed heap (GC pause-free):

realtime nogc { body }

Внутри realtime nogc — никаких аллокаций, кроме как в region’е (05-memory.md → D6).

Это расширение realtime, опциональное. Базовый realtime запрещает suspend, не аллокацию.

Грамматика

realtime-block = 'realtime' [ 'nogc' ] block

nogc — опциональный модификатор для жёсткого режима.

Async концепт полностью удалён из языка

Это окончательно фиксирует:

  • Async не существует как тип эффекта.
  • Не пишется в сигнатурах.
  • Не упоминается в effect-row.
  • Программист про него не знает.

Если нужна гарантия не-приостановки — realtime { body }. Это inverse-маркер: дефолт «может suspend», realtime — «гарантированно нет».

Почему

  1. Inverse-семантика лучше для AI-first. В большинстве кода suspend разрешён (это дефолт). Программист пишет специальный маркер только когда отличается от дефолта. Меньше cognitive load.
  2. Реальные use-cases: real-time системы, hot loops в backend, lock-критичный код. Не везде, но достаточно часто.
  3. Прецедент: Erlang has :hibernate for non-yielding paths, Rust has #[no_std] for no-allocation, Java has @RealTime annotations. Nova consolidates через один keyword.
  4. Симметрия с forbid: оба — runtime-ограничения. forbid для эффектов в типах, realtime для невидимой приостановки.

Что отвергнуто

  • Async как явный эффект в сигнатурах (D62) — везде в backend-коде шум.
  • @no_suspend атрибут толькоrealtime block более гибкий (зона внутри функции), атрибут это sugar.
  • sync keywordsync имеет другие коннотации (синхронизация, thread-sync) в других языках.
  • pinned keyword — слишком узкое значение (real-time terminology), не покрывает hot loops.
  • forbid Async — Async не в типах, нечего forbid’ить через type-system механизм.

Связь

  • D62 — Async ambient, не пишется в сигнатурах. D64 — inverse-механизм.
  • D63 — capability sandbox для type-system эффектов; параллельный механизм для type-effects, тогда как D64 для async-runtime.
  • 05-memory.md → D6region { ... } для GC-free аллокации; realtime nogc использует region семантику.
  • 06-concurrency.md → D14 — fiber runtime, yield-points; D64 запрещает yield внутри блока.
  • revolutionary.md → R7 — «Async — невидимая инфраструктура»; D64 формализует противоположное направление.

Цена

  • Bootstrap-компилятор (опционально):
    • Lexer: keyword realtime.
    • Parser: realtime [nogc] { body }.
    • AST: ExprKind::Realtime { nogc bool, body }.
    • Interp: runtime флаг + проверка на suspend-операциях.
    • Можно отложить — bootstrap не имеет полноценного fiber-runtime’а (синхронное исполнение); realtime no-op в bootstrap.
  • Production-компилятор: type-level проверки + runtime барьер + оптимизация (LLVM может удалить safepoint’ы внутри realtime).

Эволюция

Изначально Async был эффектом в типах, как у Koka. Опыт показал что в реальном backend-коде он везде, что обесценивает его как информативный маркер. D62 сделал Async ambient capability — не пишется в типах, но «существует» концептуально.

Дискуссия про forbid Async показала: если Async не в типах, его нельзя forbid’ить через type-system механизм. Нужен отдельный runtime-маркер. Так появился realtime { body }.

Окончательно: Async концепт удалён из языка целиком. Программист не знает про него; есть только realtime как inverse-маркер. Это приближает Nova к Go/Erlang модели «горутины могут suspend, нет async-keyword’а».


D67. ? оператор: семантика для Result через Fail, для Option через ранний return

⚠️ ОТМЕНЕНО 2026-05-10, см. D85. D85 унифицирует семантику ?: для обоих Result и Option делает ранний return обёртки. Throw-стиль через Fail теперь выражается отдельным оператором !! или явным ?? throw E. ? больше не задействует эффект Fail.

Текст ниже сохранён для исторической справки. Актуальная семантика — D85.

Что

Постфиксный оператор ? имеет две разные семантики в зависимости от типа выражения:

  1. Result[T, E]? desugar’ится в match + throw через эффект Fail[E] (D4).
  2. Option[T]? desugar’ится в match + ранний return None из текущей функции, без эффекта Fail.

На любом другом типе ? — синтаксическая ошибка.

Правило

? на Result[T, E]

Требует Fail[E] в сигнатуре функции. Точная семантика — D4:

expr?  ≡  match expr {
              Ok(v)  => v
              Err(e) => throw e          // через эффект Fail[E]
          }

? на Option[T]

Не требует эффекта в сигнатуре. Превращается в ранний return:

expr?  ≡  match expr {
              Some(v) => v
              None    => return None     // ранний выход из текущей fn
          }

Возвращаемый тип функции должен быть Option[U] — иначе compile error (return None несовместим с return type’ом).

fn first_pos(xs []int) -> Option[int] {
    let head = xs.first()?         // Option[int]; на None: return None
    if head > 0 { Some(head) } else { None }
}

? НЕ работает на Fail[E] -> T

Если выражение бросает через эффект Fail (а не возвращает Result- значение), ? после него — синтаксическая ошибка:

fn save(u User) Fail[DbError] -> () => Db.exec(...)

fn caller(u User) Fail[DbError] -> () =>
    save(u)?           // ОШИБКА: save возвращает (), не Result/Option

? ожидает значение типа Result или Option, а не throw’а — throw от save сам собой пробрасывается через Fail[DbError] в caller’е, без ?.

Семантика на handler-методах

Внутри handler-method’а ? подчиняется тем же правилам:

type Db effect {
    in_transaction[T](body fn() Db Fail -> T) Fail -> T
}

effect Db {
    in_transaction(b) => real.in_transaction(b)?    // ОШИБКА: in_transaction
                                                    // возвращает T, не Result/Option
    in_transaction(b) => real.in_transaction(b)     // правильно: throw сам
                                                    // пробрасывается через Fail
}

Это частая ошибка при написании middleware-handler’ов: программист думает «обернуть и вернуть» через ?, но ? нужен только когда callee возвращает Result/Option как значение.

Почему две семантики

  • Result — про обработку ошибок: нужен механизм propagation через стек, единый с throw. Эффект Fail[E] даёт это.
  • Option — про отсутствие значения: семантически отдельная категория, не «ошибка». Использовать Fail для каждого None — шум: lookup, find, parse_int бросали бы Fail везде. Ранний return None из функции с -> Option[T] — естественнее.

Признанное напряжение с D10 «всё — handler»

? на Optionвторой механизм control-flow в Nova: ранний return из функции, не перехватываемый через with-handler. Это признанное исключение из «всё взаимодействие с внешним миром — эффект»:

  • Option это значение, не эффект. None это валидный результат, не ошибка. Поэтому propagation через эффект-stack здесь неприменимо — нет «вверх» куда передавать.
  • Альтернатива Fail[NoneError] создавала бы фантомные эффекты в сигнатурах для каждой функции с lookup/find/parse_int, что значительно хуже по AI-first критерию (R5.2).
  • Compile-time правило тривиально: ? на Option[T] валиден только в функции с return type Option[U] для какого-то U.

Это прагматичный компромисс — в духе D62 (Fail strict, остальное ослаблено). Полная унификация через эффект-stack теряет больше чем выигрывает.

Альтернативы рассмотрены:

  • ? на Option через Fail[NoneError] — отвергнуто: засоряет сигнатуры неинформативным типом ошибки.
  • ? только на Result — отвергнуто: Option-чтение через match бойлерплейт; ? для unwrap-or-return — естественный pattern.
  • Отдельный оператор ??! для Option — отвергнуто: ?? уже используется как coalesce (D86), цена ещё одного символа выше пользы.

Что отвергнуто

  • ? на произвольном sum-type’е с двумя вариантами — слишком магично; программист может назвать варианты как угодно, парсер не знает «какой Ok какой Err».
  • ? на Result[T, E] без Fail[E] в сигнатуре — нарушает D4 правило (? через throw).
  • Auto-coercion ResultOption через ? — отвергнуто (Q-fail-coercion). Программист явно конвертирует через .ok() / .into().

Связь

  • D4? для Result + Fail[E].
  • D25throw как операция Fail[E].
  • D26 — Option/Result в prelude.
  • D62 — Fail strict, транзитивность.
  • D65Fail[any] для catch-all.

Эволюция

В D4 (изначально) ? был определён только для Result. Семантика для Option работала де-факто в bootstrap-интерпретаторе через ранний return, но не была зафиксирована — это был open question (D26 открытые вопросы).

D67 формализует обе семантики и явно отделяет от случая «callee бросает через Fail» (где ? не нужен и является ошибкой).

Что НЕ меняется

D4 продолжает определять ? для Result. D67 — расширение, не пересмотр.


D68. Stateful handlers: через closure capture или @as_handler метод record

Что

Handler-литерал (D61) содержит только методы операций — поля внутрь добавлять нельзя. Stateful handlers (handler’ы со своим состоянием) делаются одним из двух способов:

  1. Closure capture — state живёт в локальной переменной (или параметре функции-фабрики), handler-method’ы захватывают её через closure. Каноничный «лёгкий» способ.
  2. @as_handler метод record’а — state живёт в полях обычного record’а, метод record’а возвращает Effect[E], который через @field обращается к полям. Канонично когда state нужно проинспектировать снаружи после with-блока.

Правило

Способ 1: closure capture (легковесный)

State — локальная переменная или параметр свободной функции:

// State прямо в `with` — captured by closure
let mut counter = 0
with Counter = effect Counter {
    next() {
        counter += 1
        return counter
    }
} {
    do_work()
}
// counter здесь = число вызовов Counter.next() в do_work

Или через handler-фабрику с параметром:

fn make_counter(initial int) -> Effect[Counter] {
    let mut state = initial
    effect Counter {
        next() {
            state += 1
            return state
        }
    }
}

with Counter = make_counter(100) {
    do_work()
}
// state здесь недоступен — он внутри closure

Когда применять: state используется только во время handler-life’а, после with его инспектировать не нужно (или достаточно with-сnopa).

Способ 2: @as_handler метод record’а

State — поля обычного record’а с mut. Метод записи возвращает Effect[E]:

type CounterState { mut value int }

fn CounterState @as_handler() -> Effect[Counter] => effect Counter {
    next() {
        @value += 1                // обращение к полю receiver'а
        return @value
    }
}

let s = CounterState { value: 0 }
with Counter = s.as_handler() {
    do_work()
}
println(s.value)                    // публичное состояние, инспектируется снаружи

Когда применять:

  • State нужно проверить после with-блока (типичный testing-сценарий: assert s.value == expected).
  • Один state используется несколькими handler-инстансами (один handler на запись, другой на чтение).
  • State имеет смысл сам по себе как доменный объект (не deal-с-handler-detail).

Семантика @field внутри handler-литерала

Handler-литерал effect E { ... } внутри @-метода типа T — это обычное выражение, и @field указывает на receiver метода (инстанс типа T). То есть:

fn CounterState @as_handler() -> Effect[Counter] =>
    effect Counter {
        next() {
            @value += 1     // @value — это поле receiver'а CounterState,
                            // не «self» handler'а (handler не имеет полей)
            return @value
        }
    }

Внутри handler-method’а нет своего @self — handler не имеет полей. @ в теле handler-method’а ссылается на receiver внешнего метода, если handler-литерал создан внутри метода.

Почему два способа

  • Closure capture — простой, локальный, без объявления отдельного типа. Хорош для одноразовых handler’ов и тестов с in-flight state.
  • @as_handler — даёт state имя и публичный API. Хорош когда state — это часть домена (счётчик ID, кэш-стат, in-memory БД).

Это не два инструмента для одного — выбор детерминирован сценарием (нужен ли state наружу). D40 «один способ» не нарушается.

Что отвергнуто

  • Handler-литерал с полями (effect Counter { state int = 0; next() {...} }). Отвергнуто: путает handler с record’ом, парсер не однозначен, смысл «инстанс с полями + методами» не нужен — это обычный record с методом-фабрикой.
  • Скрытые «handler trait» objects (как Java: класс реализует interface). Отвергнуто: handler — обычное значение, fabriqué из closure’а или метода. Никаких неявных классов.
  • self keyword внутри handler-method’а. Отвергнуто: @ уже определён как «field/method receiver’а» в D35, использование внутри handler-литерала естественно ссылается на внешний receiver.

Связь

  • D11 — handler-литерал, основной синтаксис.
  • D31 — handler-лямбда для одно-операционных эффектов.
  • D35@-методы и @field.
  • D61handler keyword.
  • D66Self universal (можно использовать в return type’е @as_handler).

Thread safety

D68 stateful handlers работают на одном fiber’е по умолчанию. Если handler передаётся между fiber’ами через spawn/detach/ parallel forпрограммист обязан использовать thread-safe state:

// ❌ Race: shared между fiber'ами без атомика
let mut counter = 0
parallel for url in urls {
    with Counter = effect Counter {
        next() {
            counter += 1     // race condition
            return counter
        }
    } { ... }
}

// ✅ Atomic для shared counter:
let counter = Atomic[int].new(0)
parallel for url in urls {
    with Counter = effect Counter {
        next() => counter.fetch_add(1) + 1
    } { ... }
}

// ✅ Или per-fiber state:
parallel for url in urls {
    let mut local = 0
    with Counter = effect Counter {
        next() {
            local += 1
            return local
        }
    } { ... }
}

Правило: state, захваченный handler-method’ом, должен быть либо fiber-local (новый let на каждый fiber), либо thread-safe (Atomic[T], Mutex[T]). Compile-time enforcement — открытый вопрос (Q12 concurrency model). Bootstrap не проверяет.

Эволюция

D68 формализует два устоявшихся паттерна. Closure-capture использовался во всех nova_tests/ и в большинстве examples/*.nv (make_counter, in_memory_db_handler и т.д.). Паттерн через @as_handler явно ещё не использовался — D68 рекомендует его как канонический способ для stateful handler’ов с публичным state.


D85. Операторы ? и !! — унифицированное поведение для Result и Option, throw-стиль через !!

Закрывает D67 (отменён 2026-05-10). Унифицирует семантику ?: для обоих Result и Option — ранний return обёртки. Throw-стиль через Fail теперь выражается новым оператором !! или явным ?? throw E.

Что

В Nova два постфиксных оператора для работы с Option[T] и Result[T, E], выбираемых программистом по стилю обработки:

  1. expr?return-стиль: «не получилось — обёртка наверх как значение». Локальное продолжение цепочки, без эффектов.
  2. expr!!throw-стиль: «не получилось — throw через эффект Fail». Эффект попадает в сигнатуру, ловится handler’ом.

Программист на месте использования выбирает, какой стиль обработки ему нужен. Один и тот же тип (Option[T] или Result[T, E]) поддерживает оба оператора.

? больше не задействует Fail — это унификация дизайна.

Правило

expr? для Result[T, E] — return-стиль

expr?  ≡  match expr {
              Ok(v)  => v
              Err(e) => return Err(e)
          }

Внешняя функция должна возвращать Result[U, E'] где E' совместим с E (тот же тип или supertype через sum-расширение). Иначе compile error.

fn pipeline(s str) -> Result[int, ParseError] {
    let n = parse(s)?            // на Err: return Err(e)
    let v = validate(n)?
    Ok(v)
}

expr? для Option[T] — return-стиль

expr?  ≡  match expr {
              Some(v) => v
              None    => return None
          }

Внешняя функция должна возвращать Option[U]. Иначе compile error.

fn first_pos(xs []int) -> Option[int] {
    let head = xs.first()?       // на None: return None
    if head > 0 { Some(head) } else { None }
}

expr!! для Result[T, E] — throw-стиль

expr!!  ≡  match expr {
               Ok(v)  => v
               Err(e) => throw e
           }

Внешняя функция должна иметь Fail[E'] в сигнатуре, где E' совместим с E. Иначе compile error.

fn pipeline(s str) Fail[ParseError] -> int {
    let n = parse(s)!!           // на Err: throw e
    let v = validate(n)!!
    v
}

expr!! для Option[T] — throw-стиль

expr!!  ≡  match expr {
               Some(v) => v
               None    => throw RuntimeNoneError
           }

Внешняя функция должна иметь Fail[RuntimeNoneError] в сигнатуре. Иначе compile error.

fn extract(json Json) Fail[RuntimeNoneError] -> str {
    let user  = json.get("user")!!     // None → throw RuntimeNoneError
    let email = user.get("email")!!
    email.as_str()!!
}

RuntimeNoneError — unit-тип в prelude (D26), введён специально для expr!! на Option. Это отдельный тип, не вариант RuntimeError — разные категории (отсутствие значения vs аппаратные сбои).

expr?? — coalesce / кастомный fallback

Параллельно с ? и !! работает ?? (D86) — coalesce для default или явного custom-throw’а:

let port = config.get("port") ?? 8080                       // default
let port = config.get("port") ?? throw ConfigError.MissingPort   // custom throw
let port = config.get("port") ?? panic("config must have port")  // panic (D13)
let port = config.get("port") ?? exit(1, "no port in config")    // exit (D13)

?? — для случаев, когда программисту нужен конкретный fallback: конкретное значение, конкретный тип ошибки, panic, exit. !! оптимизировано под дефолтный шаблон throw’а; ?? throw E — расширенная форма для кастомизации типа. Полная семантика ?? — в D86.

Смешение ?, !!, ?? в одном выражении

Все три оператора валидны параллельно и могут сочетаться по типу вмещающей функции:

// Функция возвращает Option — используем ?
fn first_word_pos(s str) -> Option[int] =>
    s.find(' ')?

// Функция бросает Fail — используем !!
fn first_word(s str) Fail[RuntimeNoneError] -> str =>
    s.split(' ')!!.first()!!.into()

// Mix: разные операнды, разные стили
fn process(s str) Fail[ParseError] -> int {
    let raw = config.get("raw") ?? "default"
    let n = parse(raw)!!
    n
}

Парсер

!!постфиксный оператор, имеет высший приоритет (тот же уровень, что ?). Грамматически: expr!! всегда парсится как постфикс, независимо от пробелов вокруг.

Префиксное !! (двойной boolean not) формально валидно (!!cond = cond), но семантически бессмысленно — линтер может предупреждать. Конфликт с постфиксом разрешается позицией: префикс не следует за выражением, постфикс следует.

Edge-case b!!c — парсится как (b!!) c, что синтаксически бессмысленно (два выражения подряд) → compile error. Программист пишет с пробелом, оператором или скобками: b!! - c, (b!!) c_call().

Одиночный ! остаётся только префиксным (boolean not, D46 @not). Постфиксный ! не используется — оставлен под будущие расширения.

? НЕ работает на Fail[E] -> T

Если выражение бросает через эффект Fail (а не возвращает Result- значение), ? после него — синтаксическая ошибка:

fn save(u User) Fail[DbError] -> () => Db.exec(...)

fn caller(u User) Fail[DbError] -> () =>
    save(u)?           // ОШИБКА: save возвращает (), не Result/Option

? ожидает значение типа Result или Option. Throw от save сам пробрасывается через Fail[DbError] в caller’е, без ?.

То же для !!:

fn caller(u User) Fail[DbError] -> () =>
    save(u)!!          // ОШИБКА: save возвращает (), не Result/Option

Семантика на handler-методах

Внутри handler-method’а ? и !! подчиняются тем же правилам, что снаружи. Тип возврата handler-method’а определяет, какой оператор валиден.

type Db effect {
    in_transaction[T](body fn() Db Fail -> T) Fail -> T
}

effect Db {
    in_transaction(b) => real.in_transaction(b)        // правильно: throw сам пробрасывается
    in_transaction(b) => real.in_transaction(b)?       // ОШИБКА: in_transaction возвращает T
    in_transaction(b) => real.in_transaction(b)!!      // ОШИБКА: то же
}

Почему

Зачем унификация ?

В D67 ? имел две разные семантики:

  • ? на Result → throw через Fail (engaged эффект, требовал Fail[E] в сигнатуре).
  • ? на Option → ранний return None (без эффекта).

Это создавало категориальную неоднородность: один оператор выражал две разные операции. D67 признавал это «исключением из принципа эффектов» в секции «Признанное напряжение». На деле никакого «нарушения принципа» не было — Option-форма это match + return, обычные конструкции, не имеющие отношения к handler’ам. Просто дизайн D67 пытался впихнуть две разные операции в один символ ради краткости.

D85 разводит две операции на два символа? для return-стиля, !! для throw-стиля. Каждый оператор делает одно и делает это консистентно для обоих типов (Option и Result).

Зачем throw-стиль вообще

Throw-стиль через Fail — центральный механизм обработки ошибок в Nova (R1 в revolutionary.md). Handler перехватывает throw в with-блоке, реализует transaction, retry, log, тестовый mock. Без короткого синтаксиса для throw’а программисты вынуждены писать длинные match’ы или ?? throw e_from_result, что замусоривает hot-path.

!! — короткий шаблон дефолтного throw’а (E как есть для Result, RuntimeNoneError для Option). ?? throw E остаётся для кастомизации типа ошибки.

Почему !!, не !

Одиночный ! занят под boolean not (!condcond.@not()). Чтобы использовать его как постфикс, потребовалось бы правило про обязательный пробел перед префиксным !, без пробела — постфикс. Это работает, но создаёт хрупкость: !cond vs ! cond vs cond ! становятся принципиально разными.

!! решает это естественно: префикс/постфикс однозначно различаются позицией в грамматике. Никаких пробельных правил.

Дополнительные плюсы !!:

  • Визуально тяжелее ? — сигналит «здесь throw, серьёзная операция», тогда как ? визуально лёгкий.
  • Параллель с Kotlin!! там «настаиваю, иначе крах». Семантически близко к нашему «настаиваю, иначе throw».
  • Одиночный ! остаётся свободен под будущие расширения — оператор как у нас «один раз решённое не возвращаем».

Почему ? работает только на Option/Result, не на Fail

? это сахар над match + return. match работает только над значением. Функция Fail[E] -> T возвращает T, не Result[T, E] — сделать match на её результате нельзя.

Если программист хочет сконвертировать throw в Result, он использует обычный with-блок:

let r Result[int, ParseError] = with Fail[ParseError] = handler {
    fail(e) { interrupt Err(e) }
} {
    Ok(parse(s))    // parse без !! — throw сам ловится handler'ом
}

Цена миграции

D85 ломает текущий идиоматический Nova-стиль:

  • Все parse(s)? в коде с Fail[E] -> T сигнатурой перестают работать как раньше. Нужно либо переписать на parse(s)!! (если хотим оставить throw-стиль), либо изменить сигнатуру на Result[T, E] (если хотим return-стиль).

В stdlib и тестах это десятки-сотни мест. Миграция запланирована как отдельная задача (см. Plan-task post-D85).

Что отвергнуто

  • Оставить D67 как был. Категориальная неоднородность сохранилась бы.
  • Унификация через throw для обоих типов. Каждый lookup/find/ parse_int обязан был бы иметь Fail[NoneError] в сигнатуре — засоряет сигнатуры частных функций неинформативным эффектом (R5.2).
  • Унификация через ранний return для обоих + полное удаление Fail-стиля. Fail остаётся центральным механизмом языка через throw, with, handler’ы — ? это просто перестаёт быть его сахаром. Полное удаление сломало бы R1.
  • Одиночный ! под throw. Конфликт с префиксным !, требует правил про пробелы. См. «Почему !!, не !».
  • expr try (Swift-style префикс). Длиннее, не симметрично с ? (постфиксом).
  • !? или ?! как throw. ? уже занят, добавление к нему суффикса визуально путаниец.
  • Force-unwrap (Rust .unwrap()/Swift !) как краткий оператор. В Nova нет force-unwrap-оператора — для краша используется panic через ?? panic(...), для throw — !! или ?? throw. Никаких скрытых panic’ов через короткий синтаксис.

Связь

  • D67 — отменено D85.
  • D4? через Fail (отменено вместе с D67).
  • D25, D65Fail[E] остаётся центральным механизмом throw’а; !! — её краткий синтаксис.
  • D26 — prelude: RuntimeNoneError добавлен как unit-тип для expr!! на Option.
  • D13panic / exit как fallback в ?? panic(...) / ?? exit(...).
  • D46! как boolean not, остаётся только префиксом.
  • D86?? coalesce / fallback. Параллельный механизм: D85 (? / !!) для канонического return-/throw-стиля, D86 (??) для кастомного fallback’а с любым выражением.
  • R1 в revolutionary.md — обновляется: ? для Fail-стиля заменён на !! в примерах.
  • Plan 19 — план атомарной реализации (closure-rev + D85 в одном PR).

Эволюция

  • D67 (2026-04-XX) — две семантики ?, секция «Признанное напряжение» признавала кривизну дизайна.
  • D85 (2026-05-10) — унификация: ? всегда return, !! всегда throw. Обе работают для обоих Option и Result. ? отвязан от Fail. Признанное напряжение снято — это были две операции, теперь у каждой свой символ.

Миграция кода

Было (D67)Стало (D85)
parse(s)? в Fail[E] -> T функцииparse(s)!! (если parse возвращает Result) или сменить сигнатуру на -> Result
xs.first()? в -> Option[T] функциибез изменений
xs.first()? в Fail[E] -> T функцииxs.first()!! (бросает RuntimeNoneError, требует Fail[RuntimeNoneError])
lookup(k) ?? throw Eбез изменений (или lookup(k)!! если устраивает RuntimeNoneError)

Полный план миграции stdlib — отдельная задача.


D86. ?? coalesce-оператор — fallback для Result/Option

Что

expr ?? fallbackcoalesce-оператор: если expr это Some(v) или Ok(v), возвращает v; иначе возвращает значение fallback.

В отличие от ? и !!?? не требует Fail[E] в сигнатуре. Это локальная чистая операция, поглощающая ошибку/None, заменяя на fallback.

Правило

let v = lookup(id) ?? 0                // None → 0
let r = parse(s)   ?? -1               // Err(_) → -1
let port = config.get("port") ?? 8080  // default

Fallback может быть:

  • значением того же типа, что внутри Some/Ok:
    let port = config.get("port") ?? 8080
    
  • throw err (custom ошибка):
    let port = config.get("port") ?? throw MissingPortError
    
  • panic("...") (D13):
    let port = config.get("port") ?? panic("port required")
    
  • return ... для раннего выхода из enclosing fn.
  • произвольным выражением, чей тип совместим с T (внутри Some(T) / Ok(T)) или имеет тип never (throw / panic / return).

Семантически — сахар над match:

expr ?? fallback
// разворачивается в:
match expr {
    Some(v)  => v
    None     => fallback
    Ok(v)    => v
    Err(_)   => fallback
}

Err-значение не доступно в fallback — ?? его отбрасывает. Если нужен доступ — использовать match явно или expr ?? throw (пробрасывает новую ошибку, не оригинальную).

Сравнение ?, !!, ??

ОператорНа Some(v) / Ok(v)На None / ErrЭффект
expr?разворачивает в vearly-return из enclosing fn (D67)требует Fail[E] если expr это Result
expr!!разворачивает в vthrow err (для OptionRuntimeNoneError) (D85)требует Fail[E]
expr ?? fbразворачивает в vвозвращает fb (или throw/panic/return если fb это они)без эффекта для default-value fallback

Почему

  1. Локальная замена ошибки на default — частый паттерн (config, lookup в map’е, parse с fallback’ом). ? / !! для таких случаев слишком тяжёлы — заставляют завести Fail[E] в сигнатуре только ради того, чтобы тут же его catch’ить.
  2. Пуристая операция — coalesce-оператор не требует эффектной системы. Чистая функция, видна на уровне выражения.
  3. Fallback может быть любым выражением — включая throw для замены типа ошибки, или return для раннего выхода. Гибкость без накручивания grammar’а.
  4. Прецедент. Swift ??, JS/TS ??, Rust Option::unwrap_or — узнаваемая convention.

Что отвергнуто

  • ??= null-coalescing assignment (rejected.md) — десахар a ??= e ≡ a = a ?? e ломается типами: LHS Option[T], RHS T (потому что ?? разворачивает Option), type mismatch.
  • Семантика «set-if-None» для ??= (if a is None { a = e }) — отличается от других compound-assignment операторов; вводит исключение в правило десахара.
  • ?? else { ... } — лишний синтаксис; ?? уже принимает любое выражение справа, включая block-{ ... }.
  • Доступ к Err-значению через ?? |e| ... — это уже match, не coalesce. ?? для случая «ошибка не важна, default достаточен».

Связь

  • D4? early-return оператор, парный к ?? (распространение vs поглощение).
  • D85? и !! унифицированы для Result/Option; ?? — третья форма обработки.
  • D67 — старая семантика ?, поглощена D85; ссылки ?? для Option из D67 теперь указывают сюда.
  • D13panic(...) как fallback.
  • history/rejected.md — отклонённый ??=.

Эволюция

В первых ревизиях ?? описан как подсекция D4 без собственного D-номера. 2026-05-10: выделен в отдельное решение D86 для:

  • возможности независимой эволюции (?? это про fallback, ? про пробрасывание — разные роли);
  • явных ссылок из spec / docs / runtime errors;
  • симметрии с D85 (? и !!) — каждый постфиксный оператор имеет свой D.

Семантика не изменилась — это формальное выделение, не содержательный пересмотр.


D87. Effect[E, IRT] — параметризация Handler типом interrupt’а

Что

Тип Effect[E] параметризован двумя generic-параметрами: эффектом E и типом interrupt’а IRT (interrupt-return type). Полная форма — Effect[E, IRT]. Default IRT = never через D88 — то есть Effect[E]Effect[E, never].

Effect[E, never] — handler, который не делает interrupt (только return/финальное выражение в handler-method’ах). Если такой handler пытается сделать interrupt v — compile error.

Effect[E, T] (для Tnever) — handler, который может сделать interrupt v где v: T. При использовании в with-блоке type-checker унифицирует T с типом with-выражения (W) по правилам D61 секция 10.

Зачем

Без D87 тип Effect[E], возвращаемый из named функции, не сообщает о том, делает ли handler interrupt. Программист, использующий такой handler в with-блоке, не может локально (без чтения тела) понять, какой тип получит with-выражение и совместим ли он с body. Это противоречит принципу Nova R1 «эффекты и связанные контракты всегда видны в сигнатуре».

Правило

Базовая форма

type Logger effect {
    log(msg str) -> ()
}

// Handler без interrupt'а — sugar `Effect[Logger]` ≡ `Effect[Logger, never]`
fn console_logger() -> Effect[Logger] => effect Logger {
    log(msg) => println(msg)
}

// Handler с interrupt'ом типа int
fn fatal_logger() -> Effect[Logger, int] => effect Logger {
    log(msg) {
        if msg.starts_with("FATAL") { interrupt -1 }
        println(msg)
    }
}

Использование в with-блоке

// Effect[Logger, never] — interrupt запрещён, with-блок даёт T_body:
let r = with Logger = console_logger() {
    Logger.log("hello")
    "ok"                    // T_body = str
}
// r: str

// Effect[Logger, int] — IRT = int должен быть совместим с T_body:
let r = with Logger = fatal_logger() {
    Logger.log("FATAL: oom")
    "ok"                    // ❌ T_body = str, IRT = int → несовместимы
}
// COMPILE ERROR: cannot unify with-block type
//   handler interrupt type: int
//   body type:              str

Чтобы пример работал — нужно привести типы:

let r = with Logger = fatal_logger() {
    Logger.log("FATAL: oom")
    -1                       // T_body = int, совпадает с IRT
}
// r: int

или явно указать общий supertype:

let r any = with Logger = fatal_logger() {
    Logger.log("FATAL: oom")
    "ok"
}
// r: any (programmer opted into dynamic typing)

Compile-time проверки

Компилятор enforce’ит:

ПроверкаКогда
Effect[E, never] не содержит interrupt в handler-method’ахпри compilation handler-литерала
interrupt v где typeof(v) ⊑ IRTпри compilation handler-литерала
IRT ⊑ W (где W — тип with-выражения)при compilation with

Если условие не выполнено — compile error со ссылкой на конкретное место.

Inference IRT

IRT чаще всего выводится из тела handler-литерала:

// IRT выводится из interrupt-выражений:
fn make_handler() -> Effect[Logger, _] => effect Logger {
    log(msg) {
        if msg.starts_with("FATAL") { interrupt -1 }    // IRT = int
        println(msg)
    }
}
// эквивалентно:
fn make_handler() -> Effect[Logger, int] => effect Logger { ... }

Для return-position parent fn’а компилятор смотрит на тип return и проверяет совместимость с inferred IRT.

Несколько interrupt с разными типами

Если handler-method содержит несколько interrupt v_1, interrupt v_2, … — IRT выводится как наименьший общий supertype их типов:

fn make_handler() -> Effect[Logger, Result[(), str]] => effect Logger {
    log(msg) {
        if msg.starts_with("ERROR") { interrupt Err("logged error") }
        if msg.starts_with("FATAL") { interrupt Ok(()) }
        println(msg)
    }
}
// IRT = Result[(), str] — supertype Err(str) и Ok(())

Если supertype’а нет — compile error «handler has incompatible interrupt types».

Inline handler в with-блоке

Когда handler-литерал стоит прямо в with-блоке (не передаётся как value через named fn), IRT определяется по правилам D61 секция 10 (unify body ↔ interrupt). Параметризация Effect[E, IRT] тут неявная — компилятор знает контекст и не требует явных аннотаций:

let r = with Fail[E] = effect Fail[E] {
    fail(err) => interrupt -1
} {
    fetch_count()
}
// IRT inferred = int (из interrupt -1)
// W = int (из body fetch_count())
// совместимо → r: int

Migration: handler-лямбда

Handler-лямбда D31 автоматически работает с D87:

// Inline в with — IRT inferred из контекста:
with Fail[E] = |err| interrupt Err(err) {       // IRT = Result[T, E]
    Ok(work())
}

// Returned from named fn — нужен явный IRT:
fn make_fail_handler() -> Effect[Fail[E], Result[T, E]] =>
    |err| interrupt Err(err)

Что отвергнуто

  • Effect[E] без второго параметра разрешает interrupt — отвергнуто. Тогда тип не сообщает о возможности interrupt’а, и программист не может локально понять что будет в with-блоке.
  • Effect-row для interrupt’ов (fn make() -> Effect[E] interrupts T) — отвергнуто. Сложнее парсить, нет прецедентов, не композируется с generic’ами так же чисто как второй параметр.
  • Implicit IRT через inference во всех случаях — отвергнуто. Inference работает для inline и для local fn (Plan 19 first-use), но для public API функций IRT должен быть явно в сигнатуре — иначе программист (или LLM) не увидит контракт без чтения тела.

Связь

  • D61 — семантика interrupt, тип with-блока, базовое определение Effect[E]. D87 расширяет до Effect[E, IRT].
  • D31 — handler-лямбда |x| body. Совместима с D87: IRT inferred из тела.
  • D88 — default-значения generic’ов; IRT = never использует этот механизм.
  • D26never как bottom-type.
  • revolutionary.md → R1 — принцип «контракты видны в сигнатуре».

Эволюция

Зафиксировано 2026-05-10. Закрывает gap, выявленный при обсуждении closure-rev и handler-лямбды на |x|: тип Effect[E] не сообщал о способности handler’а делать interrupt, что нарушало R1 «контракты в сигнатуре».

Migration: ~10 примеров Effect[E] в spec/, где handler делает interrupt, перевести на Effect[E, IRT]. Inline handler-литералы в with-блоках не требуют миграции (IRT inferred неявно по D61).

Plan 97 amendment (2026-05-22) — Handler → Effect rename

С D142 (Plan 97 Ф.3) builtin переименован:

Pre-D142Post-D142
Handler[E]Effect[E]
Handler[E, IRT]Effect[E, IRT]
Handler[E, never]Effect[E, never]

Семантика полностью идентична — это renaming, не пересмотр. Effect[E, IRT] остаётся встроенным generic-типом с двумя параметрами (E — эффект, IRT — interrupt-return-type), default-значение IRT = never через D88.

Имя Effect выбрано для симметрии с keyword’ом литерала effect:

// declaration ─ keyword `effect` после имени
type Logger effect { log(msg str) -> () }

// literal ─ тот же keyword `effect` префиксом (без `type`)
fn console_logger() -> Effect[Logger] => effect Logger {
    log(msg) => println(msg)
}

// type-position ─ builtin `Effect[...]`
fn run(h Effect[Logger]) -> () => with Logger = h { ... }

Три use-site’а (declaration / literal / type-position) пишутся через одну вариативность keyword’а: effect — для синтаксиса, Effect[...] — для типа значения. Тавтологии «Handler для Effect» больше нет — Effect[Logger] читается как «значение-effect для протокола Logger» (то же, что Result[int, str] — «значение-result для int и str»).

Миграция (clean break, без backwards-compat) — sweep одной CL’ой по prelude / std / nova_tests / examples / spec.


D120. #pure views + axioms + #verify/#trusted handlers

Статус: Принято (Plan 33.3 Ф.9, реализовано 2026-05-14)

Решение

Эффект или протокол может объявлять #pure операции (чистые проекции состояния) и axiom — утверждения об их поведении, используемые SMT-движком при верификации контрактов.

effect Db {
    setBalance(id AccountId, x money) -> ()
    #pure balance(id AccountId) -> money
    axiom non_negative(id) =>
        balance(id) >= 0
    axiom balance_after_set(id, x) =>
        post(setBalance(id, x))(balance(id)) == x
}

with-binding для эффекта, у которого объявлены axioms, обязан явно указывать #verify или #trusted:

  • #verify — компилятор символически проверяет handler’а против axioms.
  • #trusted — axioms принимаются без proof (handler содержит FFI, IO или ветвление, не поддерживаемое V1 symbolic execution).
with #trusted Db = ffi_handler { ... }   // контракт принят на доверие
with #verify  Db = pure_handler { ... }  // V1: gate принят, Ф.9.7 — pending

Без явного атрибута использование такого handler — compile error.

Обоснование

Эффекты с axioms приобретают формальный контракт, проверяемый SMT. Это позволяет ensures Db.balance(to) == old(Db.balance(to)) + amount доказываться без знания тела handler’а — достаточно axiom’ов эффекта. Nova — единственный mainstream-язык с effect-aware contracts в сигнатуре.

Реализация

  • compiler-codegen/src/ast/mod.rsOpKind::PureView, EffectAxiom, поле axioms: Vec<EffectAxiom> в TypeDecl.
  • compiler-codegen/src/parser/mod.rs — синтаксис #pure op, axiom name(binders) => formula.
  • compiler-codegen/src/types/mod.rs — type-check axiom-body, gate #verify/#trusted.
  • compiler-codegen/src/verify/encode.rs — кодировка #pure view → UF, axiom → Z3_mk_forall_const; inconsistency check pre-flight.

Ограничения V1

  • #verify (Ф.9.6) принимает атрибут и применяет gate, но symbolic handler verification (Ф.9.7) — placeholder, реализация в Plan 33.4 Ф.1.
  • Поддерживаются только static axioms (balance(id) >= 0); axioms про state transition (post(action)(view) == X) — V2.

D115. Axiom binder — BinderType enum вместо Option<TypeRef>

Статус: Принято (Plan 33.4 P1-5, реализовано)

Решение

Параметры axiom-формулы ранее представлялись как Vec<(String, Option<TypeRef>)>, где None означало «без типа». Семантически существуют три различных состояния:

СостояниеСмысл
UntypedBinder без аннотации (axiom foo(x))
Typed(TypeRef)Binder с конкретным типом (axiom foo(x AccountId))
Generic(String)Binder через generic-параметр эффекта (axiom foo(x T))

Введён enum:

pub enum BinderType {
    Untyped,
    Typed(TypeRef),
    Generic(String),
}
pub struct BinderDef {
    pub name: String,
    pub kind: BinderType,
    pub span: Span,
}

EffectAxiom.binders теперь Vec<BinderDef>.

Обоснование

Option<TypeRef> не различал Untyped и Generic — оба давали None. Enum устраняет двусмысленность и позволяет SMT-encoder правильно выводить sort для каждого binder’а (Generic → sort из параметра эффекта).

Реализация

compiler-codegen/src/ast/mod.rs, parser/mod.rs, types/mod.rs, verify/pipeline.rs — механический рефактор (4 файла).


D118. Typed Fail[E] codegen — payload preservation via fail-frame

Status: active (spec). Реализация — Plan 61. Расширяет D25/D65/D85.

Что

throw expr где expr: T (T ≠ nova_str, e.g. record/sum variant) — payload передаётся через NovaFailFrame.error_user_payload + NovaFailFrame.error_user_type_id (NovaTypeId). Handler-arm |e: E| читает payload как (E*)payload через transparent C cast.

До Plan 61: codegen делал Nova_Fail_fail(nova_int_to_str((nova_int)val)) — silent pointer-to-int pun. Handler получал garbage string. Silent UB #1 закрыт Plan 61 Ф.2/Ф.3.

Правило

  1. Throw lowering (Stmt + Expr):

    • expr: nova_str → legacy Nova_Fail_fail(msg).
    • expr: T* или value → nova_throw_typed(msg_repr, payload, NOVA_TID_<T>). Value-types heap-boxed inline (nova_alloc(sizeof(T)) + copy).
  2. Handler-arm typed binding:

    • with Fail[E] = |e| body — compiler infer’ит e: E из effect-type (Plan 61 Ф.3 inference в desugar_handler_lambda).
    • В body Ident(e) resolves через (E*)_nova_fail_top->error_user_payload (pointer) или *(E*)_nova_fail_top->error_user_payload (value).
    • Pattern-match match e { ... } работает natural — field-access проходит через typed cast.
  3. Dispatch precedence (nova_throw_typed в effects.h):

    1. _nova_handler_Fail_any — erased typed slot (Fail ≡ Fail[any] catch-all D65 правило 1). Если установлен, вызывается с (payload, tid).
    2. _nova_handler_Fail — legacy string slot. Вызывается с msg_repr (typeid name). Handler arm может typed (читает payload через frame) или string (читает msg).
    3. Unwind через fail-frame с typed payload preserved (error_user_payload set’нут на step 0 — до dispatch).
  4. D65 правило 3 (re-throw): _nova_handler_Fail = current->prev swap во время handler-body invocation — корректно работает с typed throws потому что nova_throw_typed reuses тот же swap pattern.

  5. expr!!: codegen эмитит Nova_Fail_fail(err_payload) для bootstrap-stage Result (hardcoded на Err = nova_str). После Plan 14/56 generic Result mono’d — Err получит real type, codegen перейдёт на nova_throw_typed. Plan 61 Ф.4 removed nova_throw_value placeholder macro — был Silent UB #2 (silently замещал payload на строку "Result::Err").

Codegen representation

/* NovaFailFrame extended (Plan 61 Ф.2): */
typedef struct NovaFailFrame {
    jmp_buf            jmp;
    nova_str           error_msg;
    NovaThrowKind      error_kind;          /* USER / CANCEL / USER_TYPED */
    void*              error_reason_ptr;    /* Plan 49 typed cancel */
    void*              error_user_payload;  /* Plan 61 typed user payload */
    NovaTypeId         error_user_type_id;  /* Plan 61 type tag */
    struct NovaFailFrame* prev;
} NovaFailFrame;

/* NovaTypeId (Plan 61 Ф.1, typeid.h): */
typedef uint32_t NovaTypeId;
/* Reserved 1..16 для primitives. User types — IDs from USER_BASE = 17
 * через compile-time auto-register в codegen (splice'тся в preamble как
 * #define NOVA_TID_USER_<X> N). */

/* Erased typed slot (Plan 61 Ф.2): */
typedef struct NovaVtable_Fail_any {
    void*                            ctx;
    nova_unit                       (*fail)(void* _ctx, void* err, NovaTypeId tid);
    struct NovaVtable_Fail_any*       prev;
} NovaVtable_Fail_any;

extern __thread NovaVtable_Fail_any* _nova_handler_Fail_any;

Plan 61 followup (production-grade closure 2026-05-17)

Все 4 ранее-deferred items закрыты production-grade в followup session:

  1. Cross-effect throw в handler-arm (with Fail[A] = |e| throw B {...}) — закрыто через owner_iframe поле в NovaVtable_Fail / NovaVtable_Fail_any

    • новый TLS slot _nova_current_handler_iframe (set/restored dispatcher’ом в Nova_Fail_fail / nova_throw_typed / per-E throw entries). nova_interrupt / nova_interrupt_ptr сначала смотрят этот slot — handler-arm interrupt v jump’тся в OUR with-block, не в _nova_interrupt_top (который может быть inner nested). Это architectural fix interrupt-frame routing для cross-effect dispatch.
  2. Stdlib migrationsemver_range.parse_version мигрирован на idiomatic D65 правило 3 form (with Fail[A] = |_e| throw NewErr {...}) после Plan 61 fu#1. Other stdlib usages (retry.nv Result-wrap для last_error capture, http.nv / audit.nv convert-to-Response patterns) — legitimate patterns, не workaround; задокументировано.

  3. Generic Result typed Err(история: Plan 61 fu#3) hybrid через extended Nova_Result struct (err_typed_payload + err_typed_type_id)

    • nova_make_Result_Err_typed(payload, tid). ✅ Заменено полной мономорфизацией (Plan 59 Ф.7.5 increment 2, 2026-05-21): Result[T,E] → per-(T,E) тип NovaRes_<ok>_<err>*, где payload.Err._0 несёт реальное typed-значение Err напрямую. Err(custom_value) строит mono-инстанс (hybrid nova_make_Result_Err_typed early-return удалён); expr!! для non-str Err — nova_throw_typed с реальным payload’ом. typed-Err поля (err_typed_payload/err_typed_type_id) сохранены в схеме mono-типа для Result[T, str]-кейсов.
  4. Per-E TLS slots + per-E vtable — реализовано через preamble splice /*__PER_E_FAIL_DECLS__*/. Для each E type registered in per_e_fail_types — эмиттится typedef NovaVtable_Fail_<E> (typed (void* ctx, E* err) signature), TLS slot _nova_handler_Fail_<E>, fast-path _nova_throw_typed_<E>(E* payload) dispatcher. Dual-install в emit_with: для with Fail[E] = ... install legacy _nova_handler_Fail (current) AND per-E slot через adapter wrapper (sets typed payload в fail-frame, delegates к legacy handler). Stmt::Throw / ExprKind::Throw для concrete E emit per-E throw entry (fallback к erased path preserves payload via fail-frame).

Что отвергнуто

  • String-only Fail — ломает D65 правило 1.
  • nova_throw_value placeholder — УДАЛЁН в Plan 61 Ф.4 (Silent UB #2).
  • Full per-(T, E) Nova_Result mono struct — требует extension Plan 48/59 mono на sum types. Hybrid через extended Nova_Result (typed slot) даёт equivalent semantics для bootstrap; full mono — future polish. ✅ РЕАЛИЗОВАНО (Plan 59 Ф.7.5 increment 2, 2026-05-21): full per-(T,E) Result mono — NovaRes_<ok>_<err>*. Hybrid через extended Nova_Result снят, остался лишь как back-compat #define-алиас.

Связь

  • D25/D65 — Fail семантика, правила 1-5.
  • D85expr!! semantics.
  • Plan 11 — закрыт cross-effect throw bug в Plan 61 followup #1 (owner_iframe routing).
  • Plan 59 Ф.7.5 — full per-(T,E) Result mono struct NovaRes_<ok>_<err> ✅ реализован (increment 2, 2026-05-21); заменил hybrid extended Nova_Result из Plan 61 fu#3.
  • Plan 49 — симметричная typed-payload infra для CANCEL kanal. Plan 61 — для USER kanal. Две оси параллельны.
  • D158 — failable defer/errdefer body. Расширяет NovaFailFrame полем error_suppressed (singly-linked NovaErrorChain) для multi-error composition при cleanup-fail во время propagation. Plan 100.4.1 (2026-05-23 proposed; runtime impl extends этот D118 fail-frame layout).