← Все решения

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-маркер.

⚠️ AMENDED by Plan 118 (D216) — keyword unsafe { } restored как syntactic sugar для built-in effect handler. Под капотом: unsafe { expr }with unsafe_handler { perform UnsafeOps.<op>(expr) }. D2 spirit (всё — эффекты) preservedunsafe_handler is built-in effect handler internally. User-facing syntax ergonomic (Rust-familiar unsafe { } block). unsafe fn — declares function of unsafe type (caller must unsafe { ... } wrap call). No effect propagation up the call stack — unsafe encapsulates per fn (canonical Rust pattern). Affected ops: pointer creation/deref/ auto-deref/arith/reverse-cast/ordering-compare/&record.field/calling unsafe fn. See Plan 118 §«unsafe { } block model» и D216 §8-9.

⚠️ D2 amend (Plan 118.1.5 closeout, 2026-06-06)

#unsafe attribute scope extended от Nova fn declarations к external fn declarations. Same enforcement: call site без unsafe { } block → E_UNSAFE_CALL_REQUIRES_WRAP. Use case: FFI bindings wrapping C-side unsafe operations (dlopen, memcpy, dlerror, as_cstr_unchecked etc.) обязывают caller к explicit unsafe context. Closes [M-118.1-unsafe-attr-on-external-fn].

⚠️ D2 amend (Plan 118.1.7 closeout, 2026-06-09)

Plan 118.1.7 migrates from #unsafe attribute to unsafe fn keyword (type-consistent, per Plan 118.5 TypeRef::Unsafe + Plan 118.1.6 *unsafe fn ptr type). unsafe fn — declares function of unsafe fn type; external unsafe fn — external fn of unsafe type. Declaration syntax mirrors fn-ptr type *unsafe fn(...) (Plan 118.1.6). #unsafe fn → hard error E_UNSAFE_ATTR_DEPRECATED.

⚠️ D2 align (Plan 138.5) — fn-ptr type следует тому же no-prefix правилу, что и data-указатели (D216 §1): unsafe пишется постфиксом pointee*unsafe fn(...) (unsafe fn ptr), а не prefix unsafe * fn(...). Канонично: *fn(...) (safe) / *unsafe fn(...) (unsafe). Prefix-модификатор перед *E_POINTER_PREFIX_MODIFIER. См. D216 §9-10.

Что

Единая система эффектов заменяет два разнородных языковых механизма (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. ? для пробрасывания ошибки

🚫 RETRACTED / SUPERSEDED by D85 (2026-05-10, enforcement Plan 173 Ф.1 #3 2026-07-04). Тело ниже описывает УСТАРЕВШУЮ throw-семантику ? («работает только в функциях с Fail[E]»). Актуальный канон: ?return-only (только на Result/Option, проброс значением); в Fail-эффект-функциях ? запрещён → [E_TRY_IN_FAIL_FN], там !!/throw. Единственные исключения — consume-init ? (D196 form 2) и ? в defer-body (D158). Оставлено как historical.

Что

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

Правило

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

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

fn pipeline(s str) -> int {
    ro 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 {
    ro 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 в выражении
ro 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’ов:

ro 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)
ro alice = User { id: 1, name: "alice" }                   // record-литерал

type Logger effect { log(msg str) -> () }                  // эффект (behavior)
ro 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.

Q-note (Plan 174.4) — размер effect-handler-registry вычисляется на компиляции

Наследование handler’ов через фиберы (per-fiber snapshot save/restore, Plan 83.10.4 Ф.3) держится на таблице зарегистрированных handler-storage адресов (NovaEffectRegistry / NovaEffectSnapshot, nova_rt/effects.h). Её размер NOVA_MAX_EFFECT_STORAGES больше не хардкод-32, а compile-time N — точное число distinct-эффектов в программе (built-in Fail/Time/Mem + user-defined), которое компилятор знает из реестра effect_schemas. Проброс N (ФАКТИЧЕСКИЙ механизм, НЕ #define в теле .c): codegen эмитит на строке 1 сгенерированного .c comment-МАРКЕР /* nova-effect-count: N */; build-слой (test_runner.rs::effect_count_define_arg) читает N из маркера и передаёт -DNOVA_MAX_EFFECT_STORAGES=N (/D для MSVC) на весь cc-вызов — во все TU разом. Почему НЕ #define внутри самого .c: генерируемый TU и рантайм-TU (effects.c/runtime.c/fibers.c) компилируются как отдельные translation units в одном cc-вызове — #define только в .c дал бы NovaEffectRegistry/ NovaEffectSnapshot разного размера в разных TU → OOB-запись в TLS-registry → segfault (тот самый ABI-раскол, что реализация сознательно отвергла). -D на весь вызов держит размер массива идентичным во всех TU (ABI-uniformity); хедерный #ifndef-fallback 32 остаётся только для hand-written bootstrap-кода без маркера. Следствия: (1) прежний тихий дроп 33-го эффекта — при котором handler молча не наследовался через фибер — устранён по построению (размер = точный N, переполнение теперь — hard-fail с диагностикой, т.е. индикатор бага codegen’а); (2) per-fiber snapshot занимает ровно N указателей, без фиксированных 256 байт на каждый фибер. Это внутренняя codegen/runtime-деталь (не влияет на язык), поэтому отдельного D-блока нет — только эта заметка. Follow-up [M-174.4-effect-registry-size] Ф.2 (статические индексы эффектов, удаление рантайм-registry) — отдельным заходом.


D12. Effect erasure и dynamic effects

Что

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

Правило

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

ro 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() -> () =>
    ro 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 enum 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 Hash 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 enum 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).

Plan 125 (2026-06-05) — divergence-aware result-type inference: паттерн if cond { throw } else { val } теперь полноценно поддержан. Codegen эмитирует _nv_if : type-of-else, divergent then-ветка не участвует в join-типе. Whitelist (Ф.1-Ф.4):

  • ExprKind::Throw (Ф.1) — direct throw expr
  • ExprKind::Interrupt (Ф.3) — interrupt val внутри handler-literal
  • Call(panic, ...) / Call(exit, ...) (Ф.2) — prelude builtin’ы
  • Call(f, ...) где f объявлена -> never (Ф.3) — direct call only
  • Recursive composition (Ф.4): if/if-let/match/block, у которых все ветви diverge

Реализация codegen-local, trailing-only — последняя позиция в блоке (b.trailing или b.stmts.last() если trailing отсутствует и last-stmt = Stmt::Throw/Stmt::Return/Stmt::Expr(...)). Helper НЕ переиспользует block_diverges из type-checker’а (root cause прошлой попытки 2026-06-03 — он walked stmts и flip’ил легитимный idiom if early-cond { return X } else { compute() }).

Type-checker side (Ty::Never first-class subtype) — отдельный follow-up [M-125-type-checker-never-first-class]; codegen V1 production-ready без него.

Plan 125.1 (2026-06-05) — type-checker Ty::Never first-class: [M-125-type-checker-never-first-class] ✅ CLOSED. Дополнено codegen-fix настоящим type-side first-class subtype rule (compiler-codegen/src/types/mod.rs):

  • Ф.1 — assignable() hookpoint: if matches!(ty_of_ref(&found_tr), Ty::Never) { return Compat::Ok } — pure additive, TyCat::Other safety-net preserved
  • Ф.2 — infer_expr_type propagates never для ExprKind::Throw / ExprKind::Interrupt / Call(panic|exit|abort|unreachable, ...) + user fn’ов с return type Ty::Never (all-overloads-divergent guard)
  • Ф.3 — infer_block_trailing_typeref возвращает Some(prim_ref("never")) когда trailing diverges (top-level shape: Throw/Interrupt/never-call); conservative — не walks preceding stmts
  • Ф.4 — detect_divergent_consumable (D196 form 3) использует block_diverges для early-skip обеих веток вместо ?-propagation abort; ЛЮБОЙ divergent путь → SKIP (None)

Test coverage: nova_tests/plan125_1/ — 12 positive + 3 negative фикстуры; full plan125 (22) + plan125_followups (9) baseline preserved.

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

🚫 SUPERSEDED by D85 (throw-семантика ? устарела; enforcement Plan 173 Ф.1 #3). Актуально: ? = return-only (match { Ok(v)=>v, Err(e)=>return Err(e) }), throw-стиль — через !!. Ниже — historical.

? тоже не магия. 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 можно использовать как выражение в любой позиции:

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

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

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

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

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

fn pipeline(s str) Fail[ParseError] -> int =>
    ro 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 enum 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]:

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

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

ro 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 }
ro 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 Hash](xs []T), D72). Статический dispatch.
  • effect — контракт на наличие активного handler’а в скоупе (fn save() Db -> ()). Lookup в with-стеке, динамический dispatch.

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

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

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

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

// Место 1 — let-биндинг
ro 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:

ro h = effect Db { ... }                  // h: Effect[Db, never] (нет interrupt)
ro arr = [h, h2, h3]                       // в массив
ro 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)  {
        ro r = real_exec(q)
        return r                              // явный return
    }
}

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

ro 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
}

ro result = with Db = effect Db {
    query(q) {
        if q.template == "" {
            interrupt 42          // здесь v: int (W = int — см. body ниже)
        }
        real_query(q)             // здесь финальное выражение: []DbRow (R)
    }
} {
    ro 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 не возвращает значение нормально).

Пример:

ro 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 к своим операциям:

ro real = effect Db { query(q) => real_query(q), ... }
ro 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
}

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

ro 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-блока

ro 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:

ro 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)
ro 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 {
    ro 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 {
    ro 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 {
    mut users = []
    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 (значения)
Hashнет (у каждого значения свой hash)нетprotocolbound на T в HashMap[K Hash, 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.

  • Compare 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 {
    ro n = parse(input)?     // Fail[ParseError]
    ro 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 enum 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, но оператор + его НЕ бросает.

AMEND (Plan 140.4, 2026-06-14) — элизия пруфом. Этот overflow-panic always-on (debug И release), но Z3-доказуемо-безопасные операции элидируют checked-форму (zero-cost) — модель enforce-with-elision D24/D272. Элизия только пруфом (доказать INT64_MIN <= a OP b <= INT64_MAX): never by #unchecked — always-safe множество (loop/литералы) элидируется всегда, contract-based (requires) — лишь при enforced контрактах; недоказанные операции остаются проверяемыми и в release. Soundness неизменна (паника на реальном overflow гарантирована).

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

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

// в prelude (D26)
type Error {
    ro 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 enum 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 Hash { ... }    // COMPILE ERROR: Hash это 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 {
        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 {
    mut sum = 0
    for b in data { sum += b as int }
    sum
}

// эквивалентно:
fn checksum(data []u8) -> int {
    realtime {
        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] {
    ro 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
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] {
    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
    }
}

ro 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'ами без атомика
mut counter = 0
parallel for url in urls {
    with Counter = effect Counter {
        next() {
            counter += 1     // race condition
            return counter
        }
    } { ... }
}

// ✅ Atomic для shared counter:
ro 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 {
    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-стиль через !!

AMEND (2026-07-07, решение владельца): метод-близнец @unwrap() РЕТРАКТИРОВАН. Option[T] @unwrap() / Result[T,E] @unwrap() (prelude/core.nv) дублировали x!! (D9 — один канонический путь; факт дрейфа: 33 вызова метода при канон-операторе). Миграция [M-unwrap-twins-retraction] (волна-2 §4а): .unwrap()!!, методы снесены из прелюдии.

Закрывает D67 (отменён 2026-05-10). Унифицирует семантику ?: для обоих Result и Option — ранний return обёртки. Throw-стиль через Fail теперь выражается новым оператором !! или явным ?? throw E.

⚠️ Уточнение 2026-06-20, spec-closure 2026-07-06 (Plan 174.2): ? подтверждён return-only — только на Result/Option, проброс значением; в Fail-эффект-функциях ? запрещён (там !!/throw). Прежний throw-режим ? (семантика D67-эры, где ? на Result задействовал Fail) убран. Авто-From конверсия ошибки (Rust-стиль) рассмотрена и ОТКЛОНЕНА — держим explicit .map_err; полное обоснование — в блоке «Авто-From конверсия ошибки» ниже (не только NB-врезка). Устаревшие секции ## D4 и дубль #### ?` — сахар над match + throw“ выше несут retraction-баннеры на D85 (проставлены при enforcement 173 Ф.1 #3) — противоречие снято.

✅ ENFORCED (Plan 173 Ф.1 #3, 2026-07-04): чекер отвергает свободно стоящий ? в функции, чей return-тип не Result[T,E] / Option[T], диагностикой [E_TRY_IN_FAIL_FN] (types/mod.rs — per-fn walker в check_fn; подсказка «используй !! / throw»). Проброс ошибки значением осмыслен лишь там, где есть return Err/None. Два EXEMPT-контекста (де-риск 173 показал реальное использование):

  1. consume-init ? (D196 form 2): consume X = expr? { body }? здесь unwrap-маркер init-выражения (разворачивает Result[T,E] → T для biнga), НЕ свободный проброс; codegen эмитит throw через enclosing Fail (emit_c.rs in_fail_ctx-ветка сохранена). Форма задокументирована в D196 и остаётся каноном.
  2. ? внутри defer-body: governed by D158 (? разрешён, если enclosing fn-sig несёт Fail[E] или обёрнут with Fail), НЕ данным правилом.

Что

В 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] {
    ro n = parse(s)?            // на Err: return Err(e)
    ro 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] {
    ro 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 {
    ro n = parse(s)!!           // на Err: throw e
    ro 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 {
    ro user  = json.get("user")!!     // None → throw RuntimeNoneError
    ro email = user.get("email")!!
    email.as_str()!!
}

RuntimeNoneError — unit-тип в prelude (D26), введён специально для expr!! на Option. Это отдельный тип, не вариант RuntimeError — разные категории (отсутствие значения vs аппаратные сбои).

expr?? — coalesce / кастомный fallback

Параллельно с ? и !! работает ?? (D86) — coalesce для default или явного custom-throw’а:

ro port = config.get("port") ?? 8080                       // default
ro port = config.get("port") ?? throw ConfigError.MissingPort   // custom throw
ro port = config.get("port") ?? panic("config must have port")  // panic (D13)
ro 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 {
    ro raw = config.get("raw") ?? "default"
    ro 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 ! становятся принципиально разными. !! решает это естественно: префикс/постфикс однозначно различаются позицией в грамматике, без пробельных правил — expr!! = (expr)!!.

Глиф-логика (самосогласованность набора ?/!!/??). Несущая ось — что происходит с ошибкой: остаётся ли она значением или становится управляющим эффектом. ? (return Err/None) и ?? fb (поглотить fallback’ом) оба держат ошибку значением и не трогают эффект-сигнатуру → делят глиф ? («ошибка остаётся данными»). !! единственный пересекает границу value→effect (добавляет Fail[E] в row, throw) → берёт другой глиф ! («ошибка стала эффектом»). Смена глифа ровно на границе value↔effect — несущая семантика, не эстетика. Пространство исходов исчерпано: вернуть значением (?), бросить в эффект (!!), подставить fallback (??); четвёртый исход «краш» намеренно БЕЗ оператора (D85, только видимый ?? panic(...)).

Про удвоение !!/??: оно значит ровно одно — «это не тот односимвольный оператор, что ты подумал» (дизамбигуация от занятого моноглифа: ?? ≠ унарный ?, !! ≠ префиксный !). Удвоение НЕ кодирует «эскалацию/настойчивость» (иначе конфликтовало бы с ??, который удвоен, но не эскалация).

Мнемонические бонусы (НЕ несущие обоснования): !! визуально тяжелее ? (сигналит «здесь throw»); форма знакома по Kotlin. ⚠ Но Nova !! ≠ Kotlin !!: у нас recoverable typed throw через Fail[E] (ловится with Fail), а не непойманный NPE-crash. Одиночный постфикс ! намеренно оставлен свободным (нет force-unwrap-краша коротким синтаксисом, D85).

Анализ: 7-языковое сравнение (Rust/Go/TS/Kotlin/Java/Zig/Swift) + adversarial-оценка альтернатив (expr! / expr?! / try expr / .unwrap_throw() / «только ?? throw») — все строго хуже или не-замена; !! оптимален. См. docs/research/2026-06-29-bang-bang-operator-review.md.

Почему ? работает только на Option/Result, не на Fail

? это сахар над match + return. match работает только над значением. Функция Fail[E] -> T возвращает T, не Result[T, E] — сделать match на её результате нельзя.

Если программист хочет сконвертировать throw в Result, он использует обычный with-блок:

ro 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’ов через короткий синтаксис.
  • Авто-From конверсия типа ошибки (Rust-стиль ?). Рассмотрено: ? сам конвертит тип ошибки через From-impl, как Rust (Err(e) => return Err(From::from(e))). Отклонено (Plan 174.2, Часть B). Причины: (1) противоречит принципу «не магия / single canonical path» — авто-From = спец-правило (компилятор ищет From-impl за тебя). (2) Боль меньше, чем в Rust: у Rust нет эффектов → ошибки текут только значениями, ремаппинг на каждом слое задалбывает; у Nova Fail-эффект пробрасывает сам, а ремаппинг типов идёт явно в with Fail-хендлере — авто-From помог бы только value-style ? (ýже, не самый идиоматичный путь). (3) Скрывает конверсию в точке вызова (parse(s)? не показывает смену типа ошибки; может незаметно терять поля). (4) Асимметрия обратимости: добавить авто-From позже (если explicit задолбает) — легко и неломающе; убрать магию, когда на неё завязан код, — больно. Молодому языку — начать строго. Замена: при E ≠ E' — явный expr.map_err(|e| E'.from(e))? (видно на месте, не теряет данные молча). Ловушка при возможном пересмотре: From[T] for T (identity-blanket) существует для всех T, поэтому авто-конверсия допустима только при E ≠ E' И наличии не-identity From[E] на E'.

Связь

  • 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

AMEND (2026-07-07, решение владельца): методы-близнецы @unwrap_or(v) / @unwrap_or_else(f) РЕТРАКТИРОВАНЫ — дублировали x ?? v (правая часть и так ленивая — сахар над match, покрывает и _or_else; факт дрейфа: 29+0 вызовов методов против 2 у канон-оператора). Ниша Result-unwrap_or_else(fn(E) -> T) с доступом к ошибке — 0 использований; когда ошибка нужна — явный match. Миграция [M-unwrap-twins-retraction] (волна-2 §4а): .unwrap_or(v)?? v.

Что

expr ?? fallbackcoalesce-оператор: если expr это Some(v) или Ok(v), возвращает v; иначе возвращает значение fallback.

В отличие от ? и !!?? не требует Fail[E] в сигнатуре. Это локальная чистая операция, поглощающая ошибку/None, заменяя на fallback.

Правило

ro v = lookup(id) ?? 0                // None → 0
ro r = parse(s)   ?? -1               // Err(_) → -1
ro port = config.get("port") ?? 8080  // default

Fallback может быть:

  • значением того же типа, что внутри Some/Ok:
    ro port = config.get("port") ?? 8080
    
  • throw err (custom ошибка):
    ro port = config.get("port") ?? throw MissingPortError
    
  • panic("...") (D13):
    ro 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:
ro r = with Logger = console_logger() {
    Logger.log("hello")
    "ok"                    // T_body = str
}
// r: str

// Effect[Logger, int] — IRT = int должен быть совместим с T_body:
ro 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

Чтобы пример работал — нужно привести типы:

ro r = with Logger = fatal_logger() {
    Logger.log("FATAL: oom")
    -1                       // T_body = int, совпадает с IRT
}
// r: int

или явно указать общий supertype:

ro 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] тут неявная — компилятор знает контекст и не требует явных аннотаций:

ro 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).

D185. ResourceTrace effect — observability-only handler dispatch

Plan 110 Ф.7. Принято 2026-05-31. Статус: ACTIVE (Plan 110.4.4.a/b codegen emits enter/exit dispatch, 2026-06-01). Амендмент Plan 173 Ф.2.R1 (2026-07-04, RENAME-only): эффект переименован CleanupResourceTrace (освобождает имя Cleanup для протокола Cleanup[E], ex-Consumable, D314), операции on_scope_enter/exiton_resource_enter/exit. (Параметр timeout в enter пока СОХРАНЁН — его дроп §3a/п.8 отложен в Plan 173 Ф.5 timeout-rework, т.к. это семантическая правка с ретайром D195-override-тестов, не часть ренейма.) Observability-only effect для tracing resource-scope entry/exit. Default handler — no-op, zero-overhead если не использован. Orthogonal к Cleanup[E].@cleanup (ex-Consumable.on_exit, resource lifecycle) — слой для metrics/tracing.

Что

effect ResourceTrace {
    on_resource_enter(label str, timeout Duration) -> ()
    on_resource_exit(label str, outcome ScopeOutcome) -> ()
}

Default handler — no-op:

fn ResourceTrace.default() -> ResourceTraceHandler => ResourceTraceHandler { /* no-op */ }

Codegen integration

При входе в consume X = init() { body } codegen эмитит (если ResourceTrace effect handler активен):

perform_ResourceTrace_on_resource_enter(type_label(X), _timeout);
// ... body ...
perform_ResourceTrace_on_resource_exit(type_label(X), _outcome);

Если handler === default no-op (compile-time check) — calls elided через D194-style optimization. Zero overhead.

Handler restrictions

  1. Handler не может throw — observability должна быть idempotent. Compile error D185-resourcetrace-handler-throw если signature handler’а throw’ит.

  2. Return type должен быть () — observability-only. Compile error D185-resourcetrace-handler-non-unit-return.

  3. Handler не может suspend — observability должна быть sync relative to scope-entry/exit. Async export через off-thread queue в handler implementation если нужен.

OpenTelemetry wire format (D185 §otel)

Reference implementation CleanupHandler.to_otel(exporter):

on_resource_enter — создаёт span

attributes = {
    "resource.label":         label,
    "resource.timeout_ms":    timeout.ms(),
    "resource.start_time_ns": now_ns(),
}
span_kind = INTERNAL
parent = active_span()

on_resource_exit — закрывает span

status = match outcome {
    Success      => OK
    Failure(_)   => ERROR { code: "cleanup_failed" }
    Panic(_)     => ERROR { code: "cleanup_panic" }
}
attributes.duration_ms = (now_ns() - start_time_ns) / 1_000_000
end_time = now()

Trace context propagation

Spans nested correctly через scope-stack (D188 §R5). Parent span = enclosing scope-handler’s span. Cross-fiber propagation через D80 effect snapshot.

Compatibility

Compatible с std OpenTelemetry SDK через FFI bridge (cross-ref Plan 100.5).

Use cases

  • Production tracing — per-resource cleanup duration → APM.
  • Debugging — long-running slow cleanup → визуальные spans.
  • Audit — какие resource’ы cleanup’или в каком порядке.
  • Performance regression detection — baseline cleanup performance.

Что НЕ ResourceTrace effect

  • ❌ Не resource lifecycle — это Cleanup[E].@cleanup (ex-Consumable.on_exit, D314).
  • ❌ Не для cancel control — это shield (D188 R3).
  • ❌ Не для timeout adjustment — это scope-дедлайн supervised(deadline:/timeout:) (§3a; D192-ретракт).

Связь


D195. Application effect — nesting + finalizer scoping + cross-fiber propagation

Plan 110 Ф.8. Принято 2026-05-31. Статус: ACTIVE (Plan 110.4.6.a Level-2 + 110.4.7 cross-fiber D80 snapshot landed 2026-06-01). Application как ambient capability для top-level lifecycle: finalizers + default exit_timeout. Cross-ref D188 §R4 + D192 Level-2.

Что

effect Application {
    fn register_finalizer(f fn() -> ()) -> ()
    fn default_exit_timeout() -> Duration
}

type ApplicationHandler {
    mut finalizers                []fn() -> ()
    ro  default_exit_timeout_value Duration
}

fn Application.handler(default_exit_timeout Duration = 5.s()) -> ApplicationHandler
    => ApplicationHandler { finalizers: [], default_exit_timeout_value: default_exit_timeout }

fn ApplicationHandler @register_finalizer(f fn() -> ()) -> () => @finalizers.push(f)
fn ApplicationHandler @default_exit_timeout() -> Duration => @default_exit_timeout_value

// Handler сам Cleanup — finalizers fire при выходе из with-блока:
fn ApplicationHandler consume @cleanup(_outcome ScopeOutcome) -> () {
    for f in @finalizers.reverse() { f() }
}

Idiomatic main pattern

fn main() Io -> () {
    with Application = Application.handler(default_exit_timeout: 10.s()) {
        run_server()
        // anywhere глубоко: Application.register_finalizer(|| { ... })
    }
    // handler.cleanup fires finalizers в reverse order
}

R1 — Inner handler wins (effect-stack semantics)

with Application = h2 {
    with Application = h1 {
        // Application.X operations бьют по h1 здесь
    }
    // здесь — по h2
}

Стандартная effect-stack семантика — inner handler побеждает.

R2 — Finalizer registry NOT inherited

Inner handler h2 имеет свой пустой registry. Finalizers registered внутри with Application = h2 scope не visible снаружи; на exit h2 запускаются h2.finalizers, h1.registry не trogается.

with Application = h1 {
    Application.register_finalizer(|| println("h1.A"))
    with Application = h2 {
        Application.register_finalizer(|| println("h2.A"))
        // h2.finalizers = [h2.A]
        // h1.finalizers = [h1.A]
    }
    // h2 exits → prints "h2.A"
}
// h1 exits → prints "h1.A"

R3 — Default exit_timeout NOT inherited

h2 имеет свой default_exit_timeout_value. Если h2 создан без аргумента — использует hardcoded default 5.s(), не h1’s value:

with Application = Application.handler(default_exit_timeout: 30.s()) {  // h1
    with Application = Application.handler() {                          // h2 — 5s, не 30s
        consume tx = db.begin() { ... }  // timeout 5s
    }
}

Deliberate — позволяет inner scope override без implicit inheritance (test isolation use case).

R4 — Test isolation

Каждый test получает свой isolated Application; не shareit finalizers с runner’ом:

fn test_user_registration() Io -> () {
    with Application = Application.handler() {
        Application.register_finalizer(|| cleanup_test_db())
        run_scenario()
    }
    // finalizers fire здесь, runner не affected
}

R5 — Integration с D192

Codegen nv_resolve_exit_timeout Level-2 check:

nv_handler_t* app = nv_effect_lookup("Application");
if (app) {
    return nv_call_method(app, "default_exit_timeout");
}

Inner handler побеждает через effect-stack (R1) — nv_effect_lookup возвращает active handler from top of stack.

R6 — Cross-fiber propagation

При spawn { ... } дочерний fiber видит родительский effect-stack (D75 cancel-token model extension), включая активный Application:

with Application = Application.handler(default_exit_timeout: 10.s()) {
    spawn {
        Application.register_finalizer(|| ...)   // регистрирует в parent's handler
        consume tx = db.begin() { ... }          // использует parent's 10s
    }
}

Snapshot effect-stack at spawn-point (D80 semantics). Child видит parent’s Application даже после exit parent — refcount keeps handler alive до последнего fiber.

R7 — Boot order

Application.handler(...) constructor должен полностью завершиться до входа в with-блок. Никаких регистраций finalizer’ов во время construction — только из body. Если constructor throws — with не входит, cleanup не вызывается (D188 R1 partial-construction safety).

R8 — Abort / SIGKILL не fires finalizers

Документировано как ограничение всех языков:

  • abort() / SIGKILL / SIGSEGV → process killed; OS unmaps memory; finalizers NOT run.
  • exit(code) — fires handler.cleanup (controlled exit) → finalizers run.

#[run_on_abort] атрибут — follow-up Plan 110.X (если будет нужно).

Связь

  • D75 — CancelToken model.
  • D80 — cross-fiber effect snapshot.
  • D188 §R1 boot-order, §R5 LIFO.
  • D192 Level-2 — 3-level resolution integration.
  • D198 — realtime bypass этого Level-2.
  • Plan 100.4.1 — handler cleanup mechanism.
  • Plan 110 Ф.8.

D209 — Protocol method @ syntax + receiver mutability (Plan 108.4, 2026-06-09)

Plan: 108.4-protocol-method-receiver-mut.md. Status: ACTIVE. Depends on: D58 (structural protocols), D72 (generic bounds), D186 (#impl annotation), Plan 108.1/108.2/108.3 (default-ro family).

Что

Protocol instance-methods требуют @ перед именем метода. Receiver mutability prefix (mut/ro/consume) опционален перед @. Default = ro (consistent с Plan 108.1/108.2/108.3 default-ro paradigm для params/locals/loops/patterns).

Visual distinction — protocols vs effects:

// Effect — набор функций (нет receiver, нет @):
type Logger effect {
    log(msg str) -> ()
}

// Protocol — набор методов (есть receiver = @):
type Closeable protocol {
    consume @close() -> Result[(), Error]
}

Правило

@ обязателен перед именем instance-метода в type X protocol { } declaration. Static methods используют .method() (leading dot, без изменений). Effect effect { } blocks — без изменений, нет @.

type Next[T] protocol {
    mut @next() -> Option[T]               // mut receiver
}

type Iter[I] protocol {
    @iter() -> I                           // ro receiver
}

type Closeable protocol {
    consume @close() -> Result[(), Error]  // consume receiver
}

type Compare[T] protocol {
    @compare(other ro T) -> int            // ro receiver, ro param
}

type Hash protocol {
    @hash() -> u64                          // ro — детерминированный hash
}

Грамматика

// Protocol — @ обязателен для instance; . для static (без изменений)
proto_method_decl  ::= ("mut" | "ro" | "consume")? "@" IDENT "(" param_list? ")"
                       effect_list? ("->" type)?
proto_static_decl  ::= "." IDENT "(" param_list? ")" effect_list? ("->" type)?

// Effect — без изменений (функции без receiver)
effect_method_decl ::= IDENT "(" param_list? ")" effect_list? ("->" type)?

Default receiver: ro (без prefix’а). ro @method()@method() (explicit, но redundant).

Enforcement

Parse-time errors:

  • E_PROTO_METHOD_NEEDS_AT — instance-метод без @ (bare method()) в protocol declaration. Hint: «add @ before method name: @method()».
  • E_PROTO_METHOD_MOD_CONFLICT — multiple modifiers (mut ro @foo(), mut consume @bar()).

Type-checker errors (impl mismatch):

  • E_PROTO_IMPL_RO_FOR_MUT — protocol mut @m(), impl ro.
  • E_PROTO_IMPL_MUT_FOR_RO — protocol @m() (ro), impl mut.
  • E_PROTO_IMPL_MUT_FOR_CONSUME — protocol consume @m(), impl mut/ro.
  • E_PROTO_IMPL_CONSUME_FOR_MUT — protocol mut @m(), impl consume.

Enforcement paths:

  1. #impl(P) annotation (D186) — declares-conformance: type-checker matches every method’s receiver_mut at type-declaration site.
  2. Structural conformance (D58) — at use-site (for-in / generic bound [T Protocol]).

Сравнение с mainstream

ЯзыкReceiver mutability в protocol/trait/interface
Rusttrait Iterator { fn next(&mut self) -> Option<Self::Item> } — explicit &mut self
Swiftprotocol IteratorProtocol { mutating func next() -> Element? }mutating keyword
Gointerface { Next() *T } — implicit pointer mut
Kotlin/Javaнет static mutability tracking
Nova (Plan 108.4)protocol { mut @next() -> Option[T] }@ обязателен + mut/ro/consume, enforced

Stdlib migration (Ф.3)

All existing protocol declarations updated (Plan 108.4 Ф.3 sweep):

ProtocolСтарый методНовый метод
Next[T] (ex Iterable[T], Plan 138)next() -> Option[T]mut @next() -> Option[T]
Hashhash() -> u64@hash() -> u64
Equalequals(other Self) -> bool@equal(other Self) -> bool
Compare[T]compare(other Self) -> int@compare(other Self) -> int
Cloneclone() -> Self@clone() -> Self
Displayfmt(sb StringBuilder)@display(sb StringBuilder)
Debugdebug_fmt(sb StringBuilder)@debug(sb StringBuilder)
Cleanup[E]cleanup(...)consume @cleanup(outcome ScopeOutcome) Fail[E] -> ()
WithExitTimeoutexit_timeout_ms() -> int@exit_timeout_ms() -> int
Into[U]into() -> U@into() -> U
TryInto[U,E]try_into() -> Result[U,E]@try_into() -> Result[U, E]
Generator[T] (testing)generate() -> T, shrink(...) -> Iter[T]@generate() -> T, @shrink(...) -> Iter[T]

Bootstrap comment in std/prelude/collections.nv (explaining why @ wasn’t used) has been removed — parser now fully supports @-prefix.

Связь

  • D58 amendNext[T] signature → mut @next() (explicit receiver); Iterable[T] удалён.
  • D72 amend[T Next[U]] bound: mut consistency check at use-site.
  • D186 amend#impl(P) annotation now checks receiver_mut in addition to method signature.
  • Plan 108.1/108.2/108.3 — consistency story (default-ro everywhere).

D295 (AMENDED V2) — DnsNet effect — async DNS resolution (Plan 91.12 Ф.9 + Plan 91.13, 2026-06-16)

RECONCILE-PENDING (owner-decision 2026-07-03): TcpNet/UdpNet/DnsNet — дробление, отклоняющееся от канона D62 (ОДИН Net). Принято решение консолидировать обратно в единый Net; миграция кода едет с net byte-surface sweep Plan 178 §13.2 ([M-net-merge-to-single-effect]). До миграции этот D-блок описывает transitional split; после — амендится на Net. AddrNet ретрактируется в pure независимо (Plan 178 §13.2).

Source: Plan 91.12 Ф.9, 2026-06-16. Amended: Plan 91.13, 2026-06-16. Status: ✅ ACTIVE (V2). Связь: D365, D364, D294, Plan 91.12, Plan 91.13.

Мотивация

TcpNet.connect и UdpSocket принимают SocketAddr — числовой IP-адрес. Для подключения по имени хоста ("example.com") необходима DNS-резолюция. В runtime она асинхронна (uv_getaddrinfo через libuv callback); она должна паркировать fiber, а не блокировать поток.

Декларация (V2)

// std/net/effect.nv
#stable(since = "0.1")
export type DnsNet effect {
    lookup(host str, port u16) -> Result[[]SocketAddr, NetError]
}

Публичный API (V2)

// std/net/dns.nv
#stable(since = "0.1")
export fn SocketAddr.lookup(host str, port u16) DnsNet -> Result[[]SocketAddr, NetError] {
    DnsNet.lookup(host, port)
}

SocketAddr.lookup является основным публичным входом. Прямой вызов DnsNet.lookup через vtable также работает в V2: исправление is_generic_stub_c в emit_c.rs (Plan 91.13) устранило ошибку классификации монорфизованных generic-инстансов как stubs, что приводило к erasure Ok-типа до nova_int. Подробнее — раздел «Codegen fix».

Реализации (V2)

ФункцияОписание
real_dns_net()Конкретный handler: dns_lookup(host.as_ptr(), host.byte_len(), port)uv_getaddrinfo → fiber park → resume → строит []SocketAddr через dns_addr_at(i) для i in 0..count
mock_dns_net()Mock handler: всегда Ok([SocketAddr._from_raw(socket_addr_loopback(0))]) (Vec, один элемент)

Семантика V2

  • Возвращает все разрешённые адреса ([]SocketAddr, ≥1 элемент при успехе).
  • Запрашивает OS resolver → блокирующий вызов внутри libuv thread pool.
  • Паркует вызывающий fiber; другие fiber’ы продолжают выполнение.
  • Вызов без DnsNet effect в области видимости — compile error.
  • addrs[0] — первый (предпочтительный) адрес; addrs.len() — полное число результатов.

C runtime (compiler-codegen/nova_rt/net.c)

typedef struct {
    nova_coro*  fiber;
    nova_int    count;    // число результатов; <0 = ошибка
    void*       addrs[8]; // first 8 resolved addresses (TLS)
} NovaDnsReq;

static __thread void* _net_dns_addrs[8];
static __thread int   _net_dns_count;

static void _dns_getaddrinfo_cb(uv_getaddrinfo_t* req, int status, ...);

nova_int dns_lookup(const uint8_t* host, nova_int host_len, uint16_t port);
nova_int dns_addr_at(nova_int i);

dns_lookup возвращает count (число адресов, <0 = ошибка); адреса доступны через dns_addr_at(i) для i in 0..count. Nova-side real_dns_net() строит Vec[SocketAddr] в цикле, вызывая dns_addr_at(i) для каждого индекса.

Codegen fix (Plan 91.13)

Проблема (V1): is_generic_stub_c в emit_c.rs классифицировал монорфизованные generic-инстансы (например Nova_Vec____NovaValue_SocketAddr*) как unresolved stubs — отсутствовала проверка !name.contains("____"). Это приводило к эrasure Ok-типа до nova_int в Result-арме vtable, делая Result[[]SocketAddr, NetError] недостижимым.

Fix: добавлена проверка && !name.contains("____") в is_generic_stub_c (compiler-codegen/src/codegen/emit_c.rs). Аналогичный guard уже применялся в Vec-array-арме (line 5850) и Option-арме. Result-арм был единственным пропуском.

Тесты

  • nova_tests/plan91_12/net_v2_dns_smoke.nv — 6 тестов (4 pos + 2 neg), все PASS.
    • Pos: mock_dns_net lookup → Ok + addrs[0].is_v4() + port == 0 + multi-call + addrs.len() >= 1.
    • Neg: custom fail-mock → Err(ConnectionRefused) / Err(NotFound) preserved.
  • nova_tests/plan91_12/net_v2_dns_real_slow.nv — opt-in real DNS test (_slow suffix, NOVA_SLOW_TESTS=1).
    • assert(r.is_ok()) с реальным localhost resolver.

Маркеры (закрыты Plan 91.13)

МаркерСтатус
[M-91.13-dns-iter-boxing]✅ CLOSED 2026-06-16 — is_generic_stub_c fix + DnsNet V2 []SocketAddr
[M-91.13-real-dns-integration-test]✅ CLOSED 2026-06-16 — net_v2_dns_real_slow.nv (_slow, opt-in)

D377 — UDP Socket Split: UdpSendHalf + UdpRecvHalf (Plan 166, 2026-06-17)

Source: Plan 166, 2026-06-17. Status: ✅ ACTIVE. Связь: D365, D364, Plan 91.12, Plan 166.

Мотивация

UdpSocket требовал все операции в одном файбере — send_to и recv_from делили одни поля recv_scope/recv_slot. Это исключало паттерн «сервер-loop»: один файбер принимает датаграммы, другой одновременно отправляет ответы.

Кроме того, в send_to существовал TOCTOU-баг: recv_scope выставлялся ПОСЛЕ вызова uv_udp_send. На Windows loopback callback’может сработать синхронно до выставления recv_scope → файбер паркуется без пробуждения (TIMEOUT).

Часть 1: TOCTOU-фикс (send_to)

  • Добавлены отдельные поля send_scope + send_slot в NovaRt_UdpSocket (параллельно recv_scope/recv_slot)
  • send_to выставляет send_scope/send_slot до вызова uv_udp_send
  • Добавлен _udp_send_stop_cb для корректной поддержки cancellation
  • _udp_send_cb использует send_scope/send_slot (не recv_scope/recv_slot)

Часть 2: UDP Socket Split

export type UdpSendHalf consume value { priv handle CUdpSocket }
export type UdpRecvHalf consume value { priv handle CUdpSocket }

export fn UdpSocket consume @split() -> (UdpSendHalf, UdpRecvHalf)

// UdpSendHalf: только операции отправки
export fn UdpSendHalf mut @send_to(data str, addr SocketAddr) UdpNet Blocking -> Result[int, NetError]
export fn UdpSendHalf consume @close() UdpNet -> ()

// UdpRecvHalf: только операции приёма
export fn UdpRecvHalf mut @recv_from(max int) UdpNet Blocking -> Result[(str, SocketAddr), NetError]
export fn UdpRecvHalf mut @local_port() UdpNet -> u16
export fn UdpRecvHalf mut @local_addr() UdpNet -> SocketAddr
export fn UdpRecvHalf consume @close() UdpNet -> ()

Контракт конкурентности

  • UdpSendHalf использует send_scope/send_slot — безопасно с concurrent recv
  • UdpRecvHalf использует recv_scope/recv_slot — безопасно с concurrent send
  • Один файбер на half — внутри каждого half операции последовательны
  • Два файбера могут одновременно использовать send_half и recv_half

Семантика владения / close

  • UdpSocket.split() потребляет сокет и возвращает два consume-значения
  • Оба half ДОЛЖНЫ быть закрыты (enforced через consume type system компилятора)
  • Close использует atomic refcount: последний close фактически закрывает OS-сокет
  • UdpSocket без split: refcount=1, close() работает как прежде

Три новых операции UdpNet effect

split_socket(handle CUdpSocket) -> (CUdpSocket, CUdpSocket)
close_send_half(handle CUdpSocket) -> ()
close_recv_half(handle CUdpSocket) -> ()

Негативные случаи (проверяются компилятором)

  • Использование UdpSocket после split() → consume violation (moved value)
  • UdpSendHalf.recv_from → type error (нет такого метода)
  • UdpRecvHalf.send_to → type error (нет такого метода)
  • Незакрытый half → consume violation (consume value must be used)

D301 — TCP Stream Split: TcpReadHalf + TcpWriteHalf (Plan 91.16, 2026-06-17)

Source: Plan 91.16, 2026-06-17. Status: ✅ ACTIVE. Связь: D365, D364, D377, Plan 91.12.

Мотивация

TcpStream делил единственную пару op_scope/op_slot между connect, read и write. Это исключало паттерн полнодуплексного соединения: один файбер читает входящий поток, другой одновременно пишет ответы на том же соединении. При попытке конкурентного read+write на одной паре slot’ов park-bookkeeping одной операции затирался другой (TOCTOU, тот же класс бага, что в D377 для UDP send_to).

Это TCP-аналог UDP split из D377: я делю TcpStream на read- и write-половины с НЕЗАВИСИМЫМИ C-side park-слотами.

API

export type TcpReadHalf  consume value { priv handle CTcpStream }
export type TcpWriteHalf consume value { priv handle CTcpStream }

export fn TcpStream consume @split() TcpNet -> (TcpReadHalf, TcpWriteHalf)

// Дополнительно: write_all на самом TcpStream (loop до полной записи).
// NB: эффект `Blocking` отозван (Plan 91.15 P0, D172 завершение) — все
// сетевые операции несут только `TcpNet`; suspend/offload — внутри handler'а.
export fn TcpStream mut @write_all(data str) TcpNet -> Result[(), NetError]

// TcpReadHalf: только чтение + интроспекция адресов.
export fn TcpReadHalf mut @read(max int) TcpNet -> Result[str, NetError]
export fn TcpReadHalf @local_port() TcpNet -> u16
export fn TcpReadHalf @peer_port() TcpNet -> u16
export fn TcpReadHalf @local_addr() TcpNet -> SocketAddr
export fn TcpReadHalf @peer_addr() TcpNet -> SocketAddr
export fn TcpReadHalf consume @close() TcpNet -> ()

// TcpWriteHalf: только запись + интроспекция адресов.
export fn TcpWriteHalf mut @write(data str) TcpNet -> Result[int, NetError]
export fn TcpWriteHalf mut @write_all(data str) TcpNet -> Result[(), NetError]
export fn TcpWriteHalf @local_port() TcpNet -> u16
export fn TcpWriteHalf @peer_port() TcpNet -> u16
export fn TcpWriteHalf @local_addr() TcpNet -> SocketAddr
export fn TcpWriteHalf @peer_addr() TcpNet -> SocketAddr
export fn TcpWriteHalf consume @close() TcpNet -> ()

Контракт конкурентности

  • TcpReadHalf паркуется на read_scope/read_slot — безопасно при concurrent write.
  • TcpWriteHalf паркуется на write_scope/write_slot — безопасно при concurrent read.
  • Один файбер на half — внутри каждого half операции последовательны.
  • Два файбера могут одновременно использовать read_half и write_half на одном соединении.
  • connect-эра пары op_scope/op_slot после split не используется (connect уже завершён).

Семантика владения / close

  • TcpStream.split() потребляет поток и возвращает два consume-значения, оба несущие ОДИН и тот же C-handle (NovaRt_TcpStream*).
  • Оба half ДОЛЖНЫ быть закрыты (enforced через consume type system).
  • Close использует atomic refcount (split_refcount): split() ставит refcount=2, каждый close() делает __atomic_sub_fetch; uv_close фактически выполняется только когда последний half закрывается (refcount → 0).
  • TcpStream без split: split_refcount=0, close() работает как прежде (отдельный путь через NovaRt_TcpStream_method_close).

Операции TcpNet effect (новые)

write_all(stream TcpStream, data str) -> Result[(), NetError]
split_stream(stream TcpStream) -> (TcpReadHalf, TcpWriteHalf)
read_half_read(half TcpReadHalf, max int) -> Result[str, NetError]
read_half_close(half TcpReadHalf) -> ()
read_half_local_port / read_half_peer_port -> u16
read_half_local_addr / read_half_peer_addr -> SocketAddr
write_half_write(half TcpWriteHalf, data str) -> Result[int, NetError]
write_half_write_all(half TcpWriteHalf, data str) -> Result[(), NetError]
write_half_close(half TcpWriteHalf) -> ()
write_half_local_port / write_half_peer_port -> u16
write_half_local_addr / write_half_peer_addr -> SocketAddr

write_all семантика

write_all гарантирует запись ВСЕХ байт (в отличие от write, который может вернуть после частичной записи). На C-уровне libuv uv_write ставит в очередь весь буфер целиком, поэтому одиночный вызов либо пишет всё, либо ошибка — tcp_stream_write_all / tcp_write_half_write_all делегируют единичной записи.

Негативные случаи

  • Использование TcpStream после split() → consume violation (требует consume-binding на исходном потоке; покрыто tcp_split_stream_after_split_neg.nv).
  • TcpReadHalf.write / TcpWriteHalf.read → type error (нет такого метода).
  • Ограничение V1: consume-tracking не пробрасывается через tuple-destructuring (mut (rd, wr) = s.split()): парсер не принимает consume (rd, wr) = ..., а mut-bound значения не отслеживаются на double-consume. Поэтому double-close одной из половин НЕ ловится компилятором в V1 (refcount защищает на runtime). Followup-маркер: [M-91.16-tuple-consume-binding].

D302 — NetError.Eof, NetError @to_str(), SocketAddr @ip() rename (Plan 91.15 P1, 2026-06-17)

Source: Plan 91.15 Phase P1, 2026-06-17. Status: ✅ ACTIVE. Связь: D301, Plan 91.12.

Мотивация

Три отдельных огреха публичного API std/net, которые я закрываю одним блоком:

  1. EOF возвращался как Ok(""). Когда peer закрывал соединение, read возвращал Ok("") — неотличимо от (теоретически возможного) пустого чтения и требовало от каждого вызывающего проверять data.len() == 0. Закрытие соединения — это событие, а не данные; ему место в Err-ветке.
  2. NetError нельзя было напечатать. IoError(str)/InvalidAddr(str) несут строку, но достать её без exhaustive match нельзя — ошибка, которую нельзя залогировать, бесполезна.
  3. @host_str() — нестандартное имя. Суффикс _str протекает тип в имя метода; ни один язык так не называет (Rust: .ip()).

Изменения

(1) NetError.Eof. Новый вариант enum (после NotFound). TcpStream.read / TcpReadHalf.read теперь возвращают Err(NetError.Eof) при закрытии соединения peer’ом; Ok(data) всегда непуст.

C-контракт: tcp_stream_read_bytes / tcp_read_half_read возвращают сентинел NOVA_NET_READ_EOF (-2) на EOF (раньше 0). Nova-handler мапит -2Err(NetError.Eof), -1 → generic error, >= 0Ok(data). Константы NOVA_NET_READ_ERR/NOVA_NET_READ_EOF в net.h.

(2) NetError @to_str() -> str. Метод (без эффектов) даёт lowercase human-readable описание каждого варианта. IoError(msg)msg; InvalidAddr(msg)"invalid address: ${msg}".

(3) SocketAddr @host_str()@ip(). Полное переименование: метод в addr.nv, операция AddrNet.ip, extern socket_addr_ip (C-символ socket_addr_host_strsocket_addr_ip в net.c/net.h). Внутренний NovaRt_SocketAddr_method_host_str оставлен (не literal entry point).

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

Breaking для пользователей host_str() и для кода, полагавшегося на Ok("") как EOF-сигнал. std/net ещё #stable(since = "0.1"), но не зарелижен — миграция в рамках pre-release окна.

Дополнение — PermissionDenied / ConnectionReset (Plan 91.15 P2, 2026-06-17)

Добавил два типизированных варианта NetError, чтобы две распространённые OS-ошибки больше не сваливались в IoError(str)/BrokenPipe:

  • PermissionDenied — OS отказала в операции (UV_EACCES), например bind привилегированного порта без прав. to_str() == "permission denied".
  • ConnectionReset — peer форсированно сбросил соединение (UV_ECONNRESET). to_str() == "connection reset by peer". Раньше эта ошибка классифицировалась как BrokenPipe; теперь это отдельный вариант (BrokenPipe остаётся для «запись в закрытый peer» без RST).

C-контракт. Net-ошибки доходят до Nova-слоя строкой (вывод uv_strerror) и классифицируются в std/net/tcp.nv net_error(). Для этих двух кодов рантайм (_nova_net_uv_err в net.c) нормализует сообщение к фиксированной канонической строке (NOVA_NET_MSG_PERMISSION_DENIED / NOVA_NET_MSG_CONNECTION_RESET в net.h), поэтому строковый матч в Nova платформо-стабилен. Прочие коды проходят через uv_strerror без изменений.

Effect-naming. Зафиксировал соглашение об именах операций effect-семейства в std/net/effect.nv (per-handle префиксы listener_*/stream_*/socket_*/ read_half_*/write_half_*); существующие имена НЕ переименованы (нулевой user-visible эффект, высокий churn) — только задокументированы.

Дополнение — NetErrorio.ErrorKind проекция + TcpStream io.Read/io.Write (Plan 176 Ф.4, Q3, 2026-07-09)

Q3-решение (см. Plan 176 §3.0): один общий io.IoError{kind, raw_os, op} для io/fs/os; net не сливается в него (NetError остаётся отдельным типом со своими #stable-строками @to_str() — ИЗМЕНЕНИЙ здесь НЕТ, выбран путь «сохранить строки» вместо «обновить все net-фикстуры под общий kind_to_str», меньший дифф), но получает аддитивную best-effort проекцию:

  • NetError @to_error_kind() -> io.ErrorKind — маппинг каждого варианта на ближайший ErrorKind (ConnectionRefused/AddrInUse/AddrNotAvailable/NotFound/TimedOut/ BrokenPipe/ConnectionReset/PermissionDenied — прямые соответствия; EofUnexpectedEof; ClosedNotConnected; CancelledInterrupted; IoError(_)Other(0); InvalidAddr(_)/InvalidPortInvalidInput — лоссово, текстовая деталь не переносится). raw_os результирующего IoError — всегда 0 (исходный uv-код уже потреблён classify() при постройке NetError и не восстановим).
  • NetError @to_io_error(op str) -> io.IoError — тонкая обёртка (IoError.of).

TcpStream.@read/@write (std/net/tcp.nv) теперь возвращают Result[int, io.IoError] (через эту проекцию) вместо Result[int, NetError] — структурная конформность io.Read/io.Write (Plan 176 Ф.4(b), поверх byte-surface D407 Ф.2-Ф.4). @flush() — no-op (TCP не буферизован на стороне Nova, тот же контракт, что File, D322/D323). Остальной Net-эффект не тронут: write_all/read_to_vec/read_text/write_str, TcpReadHalf/TcpWriteHalf, UdpSocket, resolve — все по-прежнему возвращают NetError напрямую.

Координация 178: HttpError.ErrSource.Net(NetError) (std/http/error.nv) разгейчен — HttpError.from_net(kind, e) несёт типизированный NetError вместо строки-плейсхолдера; std/http/transport/real.nv (dns/connect/write) использует его. Предполагавшийся namespace-shadow (NetError.InvalidPort vs ParseUrlError.InvalidPort, см. баннер std/http/transport/real.nv/std/http/servernet/servernet.nv) при проверке не подтвердился — ParseUrlError с тех пор переименовал этот вариант в MalformedPort; std.http компилируется с прямым import std.net.{NetError} без коллизий.

Conformance: spec_tests/conformance/d302_neterror_iokind.nv.


D407 — std/net переработка: один слой FFI, байтовый транспорт, zero-copy, M:N-безопасность (Plan 183, 2026-07-06)

Source: Plan 183 Ф.0, 2026-07-06. Status: ✅ ACTIVE — Ф.0-Ф.4 SHIPPED (2026-07-06): net2.c/net2.h (Ф.1) + std/net2 .nv-обвязка (Ф.2) + потребители (std/http, тесты, examples/net/*) мигрированы (Ф.3) + M:N-стресс/эхо-замер (Ф.4, amend ниже). Остаток — Ф.5-хвост, НЕ этот D-блок: физическое удаление старого std/net/net.c + namespace-ренейм net2net, гейтовано на санацию nova_tests[M-183-old-net-removal-after-182] (docs/plans/backlog-followups.md); до этого старый слой живёт с // DEPRECATED-баннером. Амендит: D173 (байтовый транспорт вместо str), D282 (один слой FFI, без NovaRt_*_method_*), D301/D302 (split без дублирующего C-API; EOF/ошибки — коды). Связь: D357 (Http-транспорт поверх байтового Net), D322/D323 (byte-surface соседи).

Мотивация (три дефекта старого net.c)

  • Д1 двойная обёртка. net.c имитировал манглинг методов Nova (NovaRt_*_method_*)
    • второй слой literal-name (ffi.nv). C-код зависел от деталей манглинга, которых знать не должен. Порядок подключения stdlib = extern "C" (образец fs/os).
  • Д2 M:N-небезопасность. Результаты операций возвращались через 6 статических __thread-слотов (_net_tcp_read_data, _net_recv_data, _net_recv_sender, _net_dns_addrs, _net_parse_result, _net_tls_last_error). Волокна мигрируют между OS-потоками (work-stealing) → пишет в слот потока A, читает слот потока B → чужие/пустые данные. Тот же класс, что STALE-slot M:N-гонка; сюда же детерминированный сегфолт live-socket-теста.
  • Д3 str как носитель байтов. Сеть возвращает произвольные байты; str — UTF-8. Носитель обязан быть []u8; текст — явной конверсией с валидацией у пользователя.

Решение

  1. Один слой FFI. Публичные C-функции — nova_net_* с C-ABI-сигнатурами (D282 rule 2: скаляры / указатель+длина / out-параметры / код-возврата). НИКАКИХ nova_str/NovaRt_*_method_* в транспорте. Nova-типы (TcpStream, SocketAddr, …) и вся логика — в .nv поверх extern "C".
  2. Байтовый транспорт. Транспортные опы: вход (const uint8_t* buf, int64_t len), выход (uint8_t* buf, int64_t cap) -> int64_t n. Эффект Net[]u8-сигнатуры. str-удобства (read_text() и т.п.) — пользовательские .nv-хелперы через Result[str, Utf8Error], НЕ операции эффекта.
  3. Zero-copy (модель Go/Rust read(buf)->n). read: alloc_cb отдаёт libuv срез буфера вызывающего (указатель+ёмкость сохранены в handle) — сеть пишет прямо в память Nova-буфера; read_cb даёт n. write: uv_write получает указатель прямо на []u8; буфер жив на стеке волокна (консервативный GC его видит). В hot-path read/write/send/recv-payload: malloc/memcpy/nova_alloc данных = 0.
  4. Без статических слотов. Результат — значением: код-возврата (int/int64, <0 = −UV-код) + out-параметры (NovaNetAddr* sender, NovaNetAddr** dns_arr). Текст ошибки строит Nova-сторона из кода (nova_net_strerror(code, buf, cap)). Инвариант: grep -E "__thread|__declspec\(thread\)" net2.c = 0.
  5. SocketAddr = value-запись (снимает [M-net-socketaddr-value-record]): адрес — данные (16 байт адреса + порт + вид семейства + паддинг = 20-байтный образ NovaNetAddr, std/net2/addr.nv ADDR_IMAGE_BYTES), не handle. Убирает _nova_alloc_addr и _net_recv_sender-слот. Единственные неизбежные копии (как у Rust/Go, поимённо): (а) sockaddr_storageNovaNetAddr при запросе адреса (accept/peer/local); (б) sender UDP recv; (в) addrinfo→GC-массив NovaNetAddr в DNS — одним getaddrinfo-вызовом (libuv уже держит весь список адресов в колбэке, C выделяет GC-массив точного count — нет повторного/угадывающего запроса, nova_net_dns_lookup). НЕ в hot-path payload.
  6. Split без дублирующего C-API (упрощение D301). У stream-handle с рождения раздельные read_scope/write_scope park-слоты → read и write независимы (full-duplex) БЕЗ отдельного набора tcp_read_half_*/tcp_write_half_*. «Split» на Nova-стороне = раздать один handle двум half-значениям; close — по refcount (nova_net_tcp_mark_split ставит split_refcount=2, каждый close() декрементирует, реальный uv_close — на 0).
  7. Парковка/пробуждение/отмена — без изменений (park/wake поверх libuv, stop_cb + nova_loop_defer_close): M:N-корректны (park-слот в scope, не в потоке). Дефект Д2 был в передаче результатов, не в парковке.

AMEND (Plan 183 Ф.4, 2026-07-06 — loop-affinity контракт, найден M:N-стресс-тестом). Пункт 7 неполон: парковка корректна, но обнаружен отдельный M:N-инвариант, которому она подчиняется. Причина исходного UDP-флейка (~1-2/10 TIMEOUT) — НЕ lost-wake и НЕ потеря датаграммы (обе гипотезы проверены трейсом и отвергнуты), а loop-affinity: uv-handle пришпилен к libuv-loop’у, на котором создан (nova_current_loop() в bind/connect/accept); libuv-loop’ы не thread-safe, единственный cross-thread-safe вход — uv_async_send (его использует nova_loop_defer_close). Под M:N (каждый worker — свой loop) uv-оп, выданный на handle с loop’а ДРУГОГО потока (в т.ч. с main-loop _evloop, пока main крутит его uv_run(UV_RUN_ONCE) в supervised-drain), — конкурентная cross-thread мутация loop’а: req теряется, completion-callback не приходит, park_until-предикат никогда не истинен → волокно виснет навсегда. Контракт (задокументирован в заголовке net2.c, «LOOP-AFFINITY CONTRACT»): создавай handle ВНУТРИ волокна, которое им оперирует; все дальнейшие uv-опы на этом handle — только с того же волокна/worker’а. TCP не проявлял флейк, т.к. connect/accept создают stream на loop’е текущего worker’а естественно; UDP-тесты (socket.bind() в управляющем волокне, send_to/recv_from в spawn-волокнах) нарушали контракт неявно. После приведения тестов к контракту: 60/60 seq + 128/128 16-way-parallel (было ~1/40, ~1/96). Остаточный узкий класс (work-stealing миграция волокна МЕЖДУ парковками → следующий оп с чужого worker’а) и полный субстратный фикс (маршалинг issue-стороны каждого uv-опа на owning-loop-thread через defer-op-очередь, обобщение nova_loop_defer_close) — [M-183-net2-loop-affinity-cross-thread-op] (backlog, P2, НЕ регрессия — контракт достаточен для всех текущих потребителей).

Миграция

Фазная (план 183): Ф.1 новый net2.c рядом со старым; Ф.2 namespaced .nv-обвязка; Ф.3 миграция потребителей (std/http, тесты, examples) + удаление net.c/ffi.nv/ str-опов/NovaRt_*_method_* атомарно. Breaking внутри pre-release-окна (std/net ещё #stable(since="0.1"), не зарелижен). Критерии приёмки — план §4 (grep-инварианты =0, live-socket M:N-smoke детерминированно зелёный, эхо-замер не хуже старого слоя).

Ф.5-факт (2026-07-06): Ф.1-Ф.4 SHIPPED как описано выше (мотивация/решение — не изменились, реализация подтверждена построчной сверкой с net2.c/std/net2/*.nv); из пяти критериев §4 плана 4 закрыты полностью, 5-й («один слой», п.1) закрыт для нового слоя (net2.c: 0 NovaRt_*_method_*, 0 __thread) — старый net.c физически ещё существует (потребители nova_tests/plan83_12/91_12/91_15/91_16/plan178 не мигрированы, намеренно, до Plan 182), поэтому global-grep по репозиторию пока не 0; это отслеживается как [M-183-old-net-removal-after-182], не как незакрытый критерий D407. Побочные компиляторные дефекты, вскрытые в ходе реализации (НЕ дефекты этого D-блока, задокументированы в docs/plans/backlog-followups.md под Plan-183-заголовками): GC-трассировка Vec[value-record с heap-полем] сквозь vtable/generic-erasure, Result[_, XError].unwrap() на typed-error, type-inferred []u8-буфер теряющий resize, nova build ICE на consume-результате effect-операции, same-module to_str()-коллизия на int-receiver’е.


D325 — Единый fallible-контракт: публичный std возвращает Result (Plan 177, 2026-06-25)

Source: Plan 177, 2026-06-25 (после развилки A→B1→Вариант 1 + adversarial-критика). Status: ✅ ACTIVE как нейминг-канон (sign-off владельца 2026-06-25). Миграция завершена (Plan 177 ЗАКРЫТ 2026-07-04): stable-std public-fallible = Result-everywhere (Ф.2a base64/json/complex, Ф.2b parse/read_buffer + де-хардкод, Ф.2c коллекторы sequence/partition); guard + conformance (41/0) зелёные. Остаток честно маркирован (Plan 177 §14): (a) std/concurrency race2/with_timeout throw bare-str = Plan 173-домен [M-177-concurrency-throw-fallibility]; (b) весь std/_experimental = defer до стабилизации [M-177-experimental-fallible-migration]; (c) codegen-хвост [M-177-d77-codegen-4way-retract] (D77 4-way→2-way emit_c) + [M-172.1-opt-result-over-userenum-typedef-order]. Amends: D77 (08-runtime.md) — 4-way auto-derive → 2-way (убрать bare-throws Fail-форму). Retracts: D178 (08-runtime.md) — str.parse_int bare + parse_int_opt. Связь: D25 (Fail остаётся в языке), D85 (?/!!), D86 (??), D73 (From/Into), D77 (TryFrom), D178. Гигиена нумерации: D316–D324 зарезервированы планами 175/175.1/176 → взят D325; gap отмечен в spec/decisions/README.md. (2026-07-04: D316 внесён — Plan 175 Ф.1, единый источник схемы Time + TimerMetrics-split; D317–D324 остаются reserved.)

Что

Любая падающая публичная операция std возвращает Result[T, <Domain>Error]. Дуальный bare(throw)/try_(Result)/_opt(Option)-нейминг ретрактируется из std. Эффект Fail[E] остаётся механизмом языка — для пользовательского кода и внутренних хелперов; std им свои ошибки наружу не отдаёт.

Правило

  • (R0) Граница panic vs Result (D13). «Падающая операция» в R1 = expected/environmental failure (пользовательский ввод, I/O, парсинг, ресурсы среды). Contract-violation / programming error → panic per D13, НЕ Result и НЕ Fail. Пары: v[i] OOB → panic / v.get(i)Option; integer overflow, div/0 → panic; s[a..b] mid-codepoint → panic / parse_intResult. Прецедент: Rust и Zig держат panic/unreachable вне error-канала. Без R0 R1 читалось бы как «Result вместо panic» — это не так.
  • (R1) Любая падающая публичная операция std → Result[T, <Domain>Error]. Один структурный XError на домен. Нет bare-throws-близнецов, нет try_-дублей, нет _opt.
  • (R2) Имя обычное, без префикса: parse_int -> Result, read_u32 -> Result, open -> Result (как Rust str::parse).
  • (R3) Префикс try_только чтобы отличить fallible-вариант одноимённого infallible (from/try_from, into/try_into). В одиночных fallible-операциях (нет infallible-сиблинга) префикса НЕТ.
  • (R4) Option — только genuine absence (find/get/env/parent), НЕ fallibility. Критерий-тест: >1 причины отказа ИЛИ вызывающему нужна причина → Result; единственный нормальный исход «нет» → Option. Result → Option через .ok(). Никаких _opt-имён. Edge env (non-unicode путь на Windows → Result[VarError] vs документированная lossy-гарантия) — решает Plan 176.
  • (R5) Эффект Fail[E] в публичной std-сигнатуре запрещён для собственных ошибок (→ Result), но разрешён для прозрачного проброса Fail[E] из closure-параметра (effect-polymorphic forwarding: retry/parallel/in_transaction над телом пользователя).

Эргономика throw на call-site сохранена операторами (D85): expr!! (throw), expr? (проброс), expr.ok() (→Option), match (ветвление).

Почему

  1. Result безопасен в 100% операций; bare-throws — нет (для must-consume close глотает ошибку → потеря данных, см. Plan 176).
  2. Нет границы «I/O vs scalar» = нет вечного вопроса «а это куда?» (был первым же на snowflake).
  3. Ошибка-как-значение фундаментальнее, чем как-throw: кладётся в Vec, мапится, собирается, шлётся в канал; брошенный Fail — control-flow.
  4. Меньше имён на операцию (одно vs до трёх); меньше доков и путаницы «какой звать».
  5. !! уже даёт throw там, где он нужен — реальная потеря лишь 2 символа на проброс в glue-скриптах.

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

  • A (bare=throws everywhere) — close-footgun на must-consume.
  • B1 (две категории: I/O=Result, scalar=дуал + граница) — вечная граница + сложность для рядового разработчика. Концепция «двух категорий» удалена целиком.
  • Удаление эффекта Fail из языка — НЕ делаем.

Эталон

std/net — Result-everywhere, 0 Fail[. Под-паттерны: fallible-итерация @next() -> Option[Result[Item, E]] (Rust-модель: exhaustion снаружи как Option, ошибка элемента внутри как Result); absence → Option; инфаллибл-аксессор → значение.

Amend-пакет (Ред. 2, Plan 177, 2026-07-03 · sign-off владельца)

  • Nesting-канон fallible-итерации: @next() -> Option[Result[T, E]] — exhaustion (None) отделён от per-element ошибки (Err). Прежняя формулировка «DirIter.next -> Result[Item, E]» (первая ред.) уточнена — она сливала «поток кончился» и «элемент упал» в один канал.
  • Explicit exempt-list (для conformance-guard §8.2 Plan 177 — иначе false-positive на легальных Fail[):
    1. std/prelude/core.nvextern Option@unwrap / Result@unwrap с Fail[...] — это сам D85-мост !!, by-design.
    2. std/prelude/protocols.nv — protocol-member @cleanup(...) Fail[E] (Cleanup-протокол) — user-E, R5-forwarding.
    3. std/testing/property.nvassert_prop/assert_prop_msg/property/property_with (4 сигнатуры с Fail) — exempt (sign-off 2026-07-03): assert/test-DSL-семантика («упади сейчас» = смысл assert’а); миграция в Result отвергнута (шум в тестах).
  • Коллекторы []Result: работа со списком результатов — sequence: []Result[T,E] -> Result[[]T,E] (fail-fast) и partition: []Result[T,E] -> ([]T,[]E) (prelude, Plan 177 Ф.2c; прецеденты: Rust FromIterator for Result, Go errors.Join).
  • Cross-domain композиция (trade-off): авто-From-конверсия ошибок при ? отклонена (D85 amend 174.2) → смешение доменов (IoError + ParseIntError в одной fn) требует .map_err(...) на сайте либо явный domain-sum-error. Обратное (обернуть Fail-код в Result) — идиома with Fail[E] = |e| interrupt Err(e) { … } (аналог Kotlin runCatching / Swift Result(catching:)).

D316 — Time: плумбинг-эффект, единый источник схемы + TimerMetrics-split (Plan 175 Ф.1, 2026-07-04)

Source: Plan 175 (time-system-rework), Ф.1. Amends: D11/D14/D62 (prelude Time-decl), D124 (wall/monotonic-разделение). Status: ✅ ACTIVE (Ф.1 — единый источник + split; Ф.1b/Ф.3 SHIPPED 2026-07-04 — amend ниже; unit-rename side-task SHIPPED 2026-07-06 — единицы в именах опов, amend ниже, не путать с формальной Ф.4 (sleep-семантика/tolerance, остаётся TODO)). Overflow-политика — D317 ✅ SHIPPED (Ф.1c, 2026-07-06); monotonic non-regression — D318 ✅ SHIPPED (Ф.1c, 2026-07-06). Typed effect-ops (timestamp()->Timestamp в схеме, mock на typed-record’ах) — 🚩 OWNER-GATED (retire int-wire, Ф.2; см. amend).

AMEND (Plan 175 Ф.1b/Ф.3, 2026-07-04 — option C: typed .nv-слой поверх НЕизменённого int-wire-эффекта):

  • Duration/Timestamp/Monotonic — теперь value-records (single-i64 nanos, stack, zero-GC). Static-конструкторы возвращают по имени типа (-> Duration), не -> Self (self_value-trap). Monotonic.now() — value-builtin (эффектонезависим → допустим в realtime{}).
  • User-facing typed surface доставлен на .nv-обёртках, БЕЗ смены схемы эффекта: Timestamp.now() = Timestamp.from_unix_millis(Time.now()) (int-wire ms → value Timestamp); @is_past/@time_until/@elapsedint-based (@nanos vs Timestamp.now().nanos), теперь РАБОТАЮТ; value-record арифметика @plus/@minus/@times/@div/@neg/@compare/==. wait_for(Duration)/close_after(Duration)/close_at(Monotonic) — value-Duration/Monotonic пересекают C-границу by-address (extern) / .nanos (dispatch). Mock (fixed_ms/mut_clock) оперирует int ms (wire), не typed-record’ами.
  • Ф.2 (retire int-wire → typed effect-ops в схеме) остаётся OWNER-GATED: Time-decl в prelude/effects.nv (ZERO-imports-на-примитивах) не может ссылаться на Timestamp/Duration; 85/96 файлов зовут bare-int Time.sleep(N). Sign-off: «typed effect surface» (prelude⟷std.time coupling, 3 net-zero) vs «typed sugar над int-эффектом» (SHIPPED). [M-time-now-schema-mismatch] закрыт частично (user-surface typed; wire int). Связь: D25 (Fail — пред-регистрируемый эффект), D64 (Time — suspend-эффект, запрещён в realtime {}), [feedback-maximize-nv-sourcing] §3 (типы/схемы из .nv), 172.1 U.1 (codegen читает декларацию — прецедент RuntimeError/MemOrdering sum-schema). Нумерация: D316 из reserved-диапазона D316–D324 (README §gap; 175 = D316–318). Ф.1 занимает D316-slot механикой единого источника; typed-surface — amend этого же D316 в Ф.2.

Что (Ф.1)

Timeвнутренний плумбинг-эффект (как TcpNet/AddrNet): user-код ходит через type-методы (Timestamp.now() / Monotonic.now() / free sleep), не зовёт Time.op() напрямую. Схема эффекта имеет ОДИН источник — декларацию type Time effect { … } в std/prelude/effects.nv; codegen читает её оттуда (ветка RUNTIME_DEFINED_TYPES / TypeDeclKind::Effect в emit_type_decl строит effect_schemas["Time"] из методов), а не из хардкод-зеркала. 5 timer-observability-счётчиков вынесены из Time в отдельный read-only эффект TimerMetrics.

Правило (Ф.1)

  • (R1) Единый источник схемы Time: только .nv-декларация. Хардкод effect_schemas.insert("Time", …) в codegen удалён; закомментированное 5-е зеркало в std/time/duration.nv удалено. (Fail/Mem пока сохраняют pre-register — вне scope Ф.1.)
  • (R2) Int-провод сохранён без смены поведения в Ф.1: sleep(ms int) -> (), now() -> int, now_monotonic() -> int (wire raw i64; Monotonic-record оборачивается на Nova-стороне). Типизация опов — Ф.2/Ф.3 (amend).
  • (R3) TimerMetrics (NEW) — read-only introspection timer-runtime: timer_alloc_total/timer_alloc_active/timer_fired/timer_cancelled/timer_longest_pending_ms, все () -> int. Дispatch — direct-C (Nova_TimerMetrics_timer_*, nova_rt/channels.h), без vtable, симметрично Mem. НЕ suspend-эффект → разрешён в realtime {} (в отличие от Time). Тест-handler’ам Time больше не нужно стабить 5 бессмысленных опов (Q1).
  • (R4) ns — канонная единица storage+wire (уточняется в Ф.2/D317).

Почему

  1. Пять расходящихся зеркал одной схемы (prelude-decl / codegen-hardcode / C-vtable / handler-литералы / закомментированная decl) — правка требовала синхронного изменения 5 мест; единый .nv-источник убирает дрейф ([feedback-maximize-nv-sourcing] §3; прецедент RuntimeError 78 Ф.2, 172.1 U.1).
  2. TimerMetrics — интроспекция timer-runtime (Plan 66 territory), не «время»: держать её в Time раздувало плумбинг-эффект и заставляло каждый mock-clock-handler стабить read-only счётчики (Q1).
  3. Ф.1 — refactor без смены поведения (int-провод неизменен) → низший риск; типизация и overflow-безопасность идут отдельными фазами поверх стабильного единого источника.

AMEND (owner decision, 2026-07-06 — единицы времени в именах операций; side-task вне формальной Ф-нумерации плана 175, отдельно одобрен владельцем):

  • Time-эффект переименован без смены поведения провода: now()now_unix_ms(), now_monotonic()now_monotonic_ns(). sleep(ms int) не тронут — единица уже в имени параметра.
  • Факт-единицы провода (зафиксированы, не изменены этим amend’ом): now_unix_ms() — миллисекунды Unix-epoch (см. Timestamp.from_unix_millis(Time.now_unix_ms()) в std/time/duration.nv); now_monotonic_ns() — наносекунды (_nova_monotonic_ns() в nova_rt/fibers.h оборачивает uv_hrtime() напрямую, без деления).
  • Сахар: Duration.@sleep() (NEW, std/time/duration.nv) — Time.sleep(@to_millis_ceil()), округляет ВВЕРХ до целых миллисекунд (никогда не спит МЕНЬШЕ запрошенного; усечение ns→ms вниз недосыпало бы).
  • Почему: голое now()/now_monotonic() не сообщает единицу на call-site — читатель должен помнить конвенцию или лезть в докблок. Имя операции = единственный источник правды на месте вызова (симметрично sleep(ms int), где единица уже в параметре).
  • Обновлены все вызовы в std/ (schema-декларация, std/testing/handlers.nv mock-handler’ы, std/time/duration.nv, std/concurrency/*, std/_experimental/concurrency/rate_limiter.nv); codegen (emit_c.rs) схему НЕ хардкодит (читает из .nv, R1) → изменений в диспатч-логике не потребовалось, только докблок-комментарии.

AMEND ([M-time-default-handler-not-wallclock], 2026-07-06 — боевой default-обработчик now_unix_ms() отдавал monotonic uptime вместо wall-clock):

  • Дефект: default (без with Time = handler {...}) обработчик Time.now_unix_ms() вызывал _nova_time_default_now()_nova_monotonic_ms() (uv_hrtime()-based, epoch реализация-зависим, фактически uptime процесса), хотя факт-единица D316 деклараровала «unix epoch ms» (§ выше: Timestamp.from_unix_millis(Time.now_unix_ms())). Любой боевой код, читающий Timestamp.now() как настоящее календарное время (логи, TTL, сравнение с внешними timestamp’ами) без явного with Time = handler, получал ложный epoch.
  • Фикс: новая _nova_wall_unix_ms() (nova_rt/fibers.h, рядом с _nova_monotonic_ms/_nova_monotonic_ns) — настоящий wall-clock через uv_gettimeofday(uv_timeval64_t*) (libuv, POSIX gettimeofday-эквивалент на всех платформах); _nova_time_default_now() переключён на неё. Nova_Time_now_unix_ms/Nova_Time_now_ms/Nova_Time_now_ns (default-путь, без handler’а) получают исправление автоматически — все три делегируют к _nova_time_default_now().
  • НЕ затронуто: now_monotonic_ns() (_nova_monotonic_ns()/uv_hrtime() — монотоника,D124/D318 non-regression), mock-обработчики fixed_ms/mut_clock (std/testing/handlers.nv — подменяют весь vtable, свой now_unix_ms-слот).
  • Тест-детектор: std/time/units_test.nvTimestamp.now() без with-обработчика > 1_700_000_000_000 мс (после 2023-11-14; monotonic uptime короткого теста — единицы-десятки секунд, на порядки меньше).
  • Нумерация: amend того же D316 (боевой default-handler wall-clock ops — та же секция D316, что и unit-rename amend выше).

D317 — Duration/instant overflow-policy: trap-default + checked_*/saturating_* (Plan 175 Ф.1c, 2026-07-06)

Source: Plan 175 (time-system-rework), Ф.1c. Amends: D316 (ns-канон → overflow-safe арифметика). Реализация: std/time/duration.nv (чистый .nv-слой; codegen НЕ тронут). Status: ✅ ACTIVE / SHIPPED. Тесты: inline unit-блоки std/time/duration.nv; spec_tests/conformance/d317_duration_overflow_policy.nv; trap-фикстуры nova_tests/time/rt/* (EXPECT_RUNTIME_PANIC); cross-module nova_tests/time/plan175_f1c_overflow_safe.nv. Нумерация: D317 из reserved-диапазона D316–D324 (175 = D316–318).

Что

Duration/Timestamp/Monotonic — знаковые i64-ns записи. До Ф.1c ВСЕ операторы (@plus/@minus/@neg/@times/@div/@abs) были сырой unchecked i64 — two’s-complement WRAP на ±292 годах (Go-ловушка «the trap to avoid»). D317 вводит 3-tier дисциплину (Rust/Swift-паритет; бьёт Go silent-wrap и Zig build-mode-UB).

Правило (3-tier)

  • (R1) Операторы траппят. +/-/унарный -/*// на Duration паникуют на overflow в debug И release — никогда silent wrap (Go-антипример), никогда build-mode-зависимость (Zig ReleaseFast-UB антипример; Swift integer-арифметика трапает всегда = прецедент). Реализация — module-private *_or_trap хелперы поверх явной overflow-детекции (bare i64 +/* wrap by design → overflow детектируется ЯВНО, не полагается на trap примитива).
  • (R2) checked_*Option[T]. @checked_add/@checked_sub/@checked_mul/@checked_div на Duration; @checked_add/@checked_sub на Timestamp; @checked_duration_since на Monotonic (D318). None на overflow/÷0/i64::MIN÷-1.
  • (R3) saturating_* → clamp. @saturating_add/@saturating_sub/@saturating_mul на Duration → clamp к ±(2⁶³−1) (симметрично; i64::MIN = -2⁶³ исключён, домен симметричен → @neg/@abs тотальны). Инстанты Timestamp/Monotonic @plus(Duration)/@minus(Duration)/@minus(инстант)saturate at i64-boundary (зеркало Go addSec-clamp).
  • (R4) Асимметрия two’s-complement. @abs(i64::MIN) saturate к i64::MAX (НЕ UB/wrap; |i64::MIN| > i64::MAX). @neg(i64::MIN) → trap. @div(0) и @div(i64::MIN, -1) → trap.
  • (R5) f64-конверсии. from_secs_f64/@times(f64)/@div(f64) (в т.ч. ÷0.0±inf) — trap на NaN/±inf/out-of-i64-range; non-trapping варианты try_from_secs_f64/@try_mul_f64/@try_div_f64Option. Не молчаливый мусор-cast (Rust mul_f64(NaN) паникует = прецедент).

Границы / честные уступки (Q11/Q16)

  • Duration = знаковый i64 ns, диапазон ±(2⁶³−1) ns ≈ ±292 года.
  • Timestamp = unix-epoch ns, окно 1677-09-21 .. 2262-04-11 (i64 ±292y, Q16) — контракт задокументирован. Zig nanoTimestamp() -> i128 не имеет 2262-горизонта; Nova принимает i64 осознанно (i128 ломает Q2 single-i64 scalar-bridge и value-ABI ради горизонта >2262). from_unix_nanos(i64::MAX) + checked_addNone; @plus → saturate (НЕ wrap в 1677) — pos-фикстура d317.
  • Отложено: публичные консты Duration.MAX/Duration.MIN (Plan 178 запрашивал @timeout(Duration.MAX)) НЕ введены — user type-const с именем MAX/MIN шэдоуит builtin numeric .MAX/.MIN в type-set-bound generics (fn[T Ints] f(x T) => x == T.MAX, spec_tests d310) → мис-типизация T.MAX как record + CC-FAIL. Фикс — в checker member-const-резолюции (172-зона, owner-gated). Follow-up [M-175-type-const-max-shadows-builtin]. Saturation-границы доступны через internal i64_max()/i64_min() — функциональность D317 полная без публичной консты.

Почему

Silent two’s-complement wrap на ±292y — это ровно Go-ловушка; Rust/Java/Kotlin/Temporal/Swift детектят overflow. Nova достигает паритета Rust/Java/Swift и обходит Go (silent-wrap) и Zig (UB-в-ReleaseFast, build-mode-зависимость). Trap-default безопасен by construction; checked_* — Rust-эскейп для восстановления; saturating_* — для «no timeout»-семантики (Plan 178).

D318 — Monotonic: non-regression + clock-source contract (Plan 175 Ф.1c, 2026-07-06)

Source: Plan 175, Ф.1c. Amends: D124 (wall/monotonic-разделение). Реализация: std/time/duration.nv. Status: ✅ ACTIVE / SHIPPED. Тесты: spec_tests/conformance/d318_monotonic_non_regression.nv; inline std/time/duration.nv; nova_tests/time/plan175_f1c_overflow_safe.nv.

Правило (контракт из двух частей)

  • (R1) Non-regression. Monotonic never goes backwards by contract. При кажущемся регрессе часов (later mark < earlier — HW/VM/OS-баг, JDK-6458294): @elapsed_since SATURATE-to-ZERO (возвращает Duration.ZERO, никогда negative, никогда panic, без global-lock — урок Rust 1.60-saga, стабильный контракт, не флип-флопить). @checked_duration_since(other)None на регрессе, Some(self − other) иначе (Some(ZERO) на равенстве). Monotonic ± Duration → saturate at boundary (D317). Monotonic non-serializable (process-local; Ф.6 верифицирует отсутствие derive-пути — Q13).
  • (R2) Clock-source (Q14). monotonic() читает uv_hrtime(): Linux CLOCK_MONOTONIC / macOS mach_absolute_time (оба suspend-EXCLUDED) / Windows QPC (suspend-поведение платформозависимо). Nova гарантирует только монотонность + non-regression, НЕ suspend-inclusion; sleep_until через сон устройства = unspecified-but-monotonic. Индустрия расходится (Zig Instant = CLOCK_BOOTTIME, Rust/Go = MONOTONIC, Swift экспонирует ОБА) → молчание = footgun. BOOTTIME-аналог (ContinuousClock) → [M-monotonic-boottime] (вводить при use-case).
  • (R3) Infallibility (Q15). monotonic() infallible by contract на tier-1 libuv (Win/Linux/macOS; uv_hrtime не фейлит); Zig-style error-union отклонён (вирусит call-sites ради платформ, которых нет).

Почему

HW/VM/OS могут дать кажущийся регресс монотонных часов; паниковать на hot-path (retry-budgets, deadlines) недопустимо, лочить (Rust 1.60-saga) — тоже. Saturate-to-zero + checked_*-эскейп = стабильный, lock-free, negative-free контракт. Раздельные типы (D124) + non-serializable Monotonic закрывают Go-footgun (m=… течёт в String()).

D322 — io-core: io.Read/io.Write/io.Seek, IoError, Io effect (Plan 176 Ф.1, 2026-07-04)

Статус: IMPLEMENTED (Ф.1 — io-core; fs=D323 Ф.2 / os=D324 Ф.3 — реализованы). Модуль std/io.

Протоколы (byte I/O)

type io.Read  protocol { mut @read(buf mut []u8) -> Result[int, IoError] }
type io.Write protocol { mut @write(data []u8) -> Result[int, IoError]; mut @flush() -> Result[(), IoError] }
type io.Seek  protocol { mut @seek(pos SeekFrom) -> Result[int, IoError] }
type SeekFrom | Start(int) | End(int) | Current(int)   // всё int (i64); Start(<0) → InvalidInput
  • Эффект-агностичны (Q15, D122-amended): конформер несёт СВОЙ плумбинг-эффект (FileFs, TcpStreamTcpNet, консоль→Io), всплывающий транзитивно при мономорфизации. Generic-вызовы через io-bound — mono-dispatch only (vtable для effectful-bounds запрещён).
  • Sibling prelude text-sink Write (D374): байтовый io.Write ссылается квалифицированно (io.Write); мост text→bytes — явный write_str.
  • EOF/partial/EINTR-контракт (Q9): readOk(0) = EOF только при непустом буфере; short-read — норма; partial-write легален (write_all loop); Ok(0) mid-write → WriteZero; Interrupted(EINTR) — retry в std-хелперах. НЕ Go (n>0, EOF).

Хелперы (free generic fns, mono-dispatch)

read_exact(→UnexpectedEof) / read_to_end / read_to_string(→InvalidData на невалидном UTF-8, Q11) / write_all(→WriteZero) / write_str / copy / lines(Q7: strip trailing \r, финал без \n — yield, embedded lone \r НЕ сепаратор — делегат str.@lines) / byte_lines(raw \n-split). In-memory конформеры: BytesReader (Read+Seek, cursor), BytesWriter (Write, growable sink).

IoError (структурный, Rust ErrorKind-precedent)

type IoError { ro kind ErrorKind, ro raw_os int, ro op str }   // Ф.2 добавит path Option[Path] + boxed source
type ErrorKind | NotFound | PermissionDenied | AlreadyExists | ... | Unsupported | Other(int)   // OPEN → wildcard-arm обязателен
  • Heap record (НЕ value): как Rust io::Error (внутренне boxed) — pointer-sized, дёшево течёт через Result[T, IoError] в generic-хелперах (by-value record ловит Result[T, ValueRecord]-mono-gap).
  • raw_os authoritative; kind = kind_from_errno(raw_os) — best-effort projection (общие POSIX errno; редкие → Other(raw), §3b). Per-op error sets (Zig) — considered/REJECTED (Q14): один открытый ErrorKind
    • raw_os + (Ф.2) source-chain композируется, а не дробит обработку.
  • Utf8Error{byte_offset} + str.from_bytes(bytes)->Result[str, Utf8Error] — Ф.0.5 (D325-канон; ретайр интринзика str.try_from([]u8)).

BufReader / BufWriter (Q10, D133)

  • BufWriter[W] consumemust-consume (D133): @close() (flush + Result); незакрытый = compile-error D133-not-consumed; double-close = use-after-consume. Нет silent flush-on-drop. Бьёт Go bufio.Flush / Rust Drop-swallow / Zig ручной flush (§1a #1). @write/@flush/@write_str — io.Write.
  • BufReader[R] — буферизует чтения chunk’ами; сам io.Read.

Io effect (консоль, мокабельна, §3c)

type Io effect {
    read_in(buf mut []u8) -> Result[int, IoError]    // buffer-fill (Result[[]u8] Ok-payload эрейзится vtable'ом)
    write_out(data []u8) -> Result[int, IoError]
    write_err(data []u8) -> Result[int, IoError]
}
  • Хендлы stdin()/stdout()/stderr() конформят io.Read/io.Write поверх Io.
  • real_io() — fd-хуки io_read_fd/io_write_fd (nova_rt/io_console.h, C stdio FILE*; return -errno на ошибке → IoError.from_os).
  • mock_io(cap IoCapture) — capture stdout/stderr + scripted stdin; детерм. консоль-тесты без терминала (мокабельность, §1a; носитель §8.4).

Реализационные ноты (обход codegen-ограничений; НЕ упрощения семантики)

  1. Heap IoError (см. выше) — by-value record не мономорфизируется в generic-Result.
  2. Хелперы инлайнят циклы (не форвардят один bounded-generic в другой generic-fn — чекер не проносит bound через такой форвард).
  3. BufReader/BufWriter строятся с ЯВНЫМИ type-args (BufWriter[BytesWriter].new(...)): inference-only конструкция generic-wrapper’а не материализует мономорфизированные методы (иначе — NULL-stub → крах). Followup [M-176-generic-wrapper-mono-inference].
  4. SeekFrom.start/end/current — статические конструкторы (cross-module литерал payload-варианта SeekFrom.Start(n) ловит checker-gap на возвратном типе конструктора; pattern-match не затронут). Followup [M-176-xmod-payload-variant-ctor].

D323 — fs: byte-backed Path, Fs effect, File must-consume, Metadata (Plan 176 Ф.2, 2026-07-04)

Статус: IMPLEMENTED (Ф.2). Модуль std/fs. Строится над io-core (D322): File impl io.Read/io.Write/io.Seek.

byte-backed Path (Q1)

type Path value { ro bytes []u8, ro style PathStyle }   // НЕ str — несёт raw OS-байты
type PathStyle | Posix | Windows
  • value-record над []u8 (Rust OsStr/Path, Swift-system FilePath, Zig []const u8): non-UTF-8 Unix / WTF-8 Windows имена round-trip’ят лосслесс. from_str/from_bytes (host-style), posix/windows (pinned style — один тест-прогон проверяет ОБЕ платформы), styled.
  • Lexical (pure, без effect): is_absolute (Posix /; Windows drive C:\ + UNC \srv\share; C:foo/\foo НЕ absolute), parent/file_name/extension/stem/components/normalize(collapse ./.., canonical separator; НЕ резолвит symlinks)/join/with_extension. to_strOption[str] (lossless), displaystr (lossy U+FFFD, print-only), as_os_bytes[]u8, equals (byte+style exact).
  • Windows separator: и / и \ разделяют; canonical output — \. Drive/UNC-префиксы распознаются.

Fs effect — ТОНКИЙ int-primitive слой (§3/§0)

Операции возвращают сырые int/i64/str-коды (не Result/Metadata/DirEntry): effect-vtable стирает rich Result[T, IoError]-возврат в canonical nova_int/nova_str пару (теряя value-IoError и Ok-record), поэтому всё построение IoError/Metadata/DirEntry — в pure-Nova обёртках ВНЕ effect-границы (там закрытый value-record keystone работает). Коды зеркалят fs.c-хуки 1:1: >= 0 успех (fd/байты/0), НЕГАТИВНЫЙ POSIX errno на ошибке. stat/lstat/fstat → 0/-errno + кэш; stat_size/stat_kind/stat_mtime_ns/… читают кэш (cooperative-safe).

Триада: real_fs() (libuv uv_fs_open/read/write/close/stat/lstat/scandir/mkdir/unlink/rename/realpath/symlink/ chmod/fsync/copyfile, park/wake ТОЧНО как net.c; best-effort-cancel Q4: uv_cancel на queued, in-flight дорабатывает) + mock_fs(MemFs) (in-memory byte-Path-дерево, ENOSPC-инъекция для close-error/torn-write тестов — детерминизм без диска, §1a-differentiator).

File must-consume (D133) — §1a #1 differentiator

type File consume { priv fd int, priv readable bool, priv writable bool, priv pos int }
fn File consume @close() Fs -> Result[(), IoError]   // ЕДИНСТВЕННАЯ явная разрядка; незакрытый = compile-error
  • Незакрытый File = D133-not-consumed; double-close/use-after = use-after-consume. Ошибка close (ENOSPC/EIO/ quota — часто видна ТОЛЬКО на close) НЕ-игнорируема. Бьёт Go defer Close()/Rust Drop/Java suppressed/Zig close()->void. NB: enforcement работает для consume-параметров + прямых consume x = Ctor(); tracking через Result/match-extract (match File.open(p){Ok(f)=>…}) — checker-gap [M-176-consume-through-result-match] (общий с net TcpStream).
  • File несёт СВОЙ pos и использует positioned read_at/write_at (portable — без непортируемого OS-offset=-1 «current position»). OpenOptions (read/write/append/truncate/create/create_new, Q13; append+truncateInvalidInput; append стартует cursor на EOF). read_at/write_at/seek/sync_all(fsync)/sync_data (fdatasync)/metadata(fstat).

Metadata / DirEntry / Permissions

Metadata (heap): len/file_type/is_file/is_dir/is_symlink/permissions/modified/accessed/created (каждый timestamp → Option[Timestamp], Plan 175; birth-time отсутствует → None). DirEntry: file_name(Path)/ file_type/path(dir). Permissions value { read_only bool, mode int } — портабельный readonly + unix-mode (Q8/Q12).

Durability + FFI (§3c)

  • write_atomic (5-шаг durable): (1) temp в ТОЙ ЖЕ директории O_EXCL → (2) write_all → (3) fsync файла → (4) atomic rename → (5) best-effort fsync родительской директории (no-op на Windows). Бьёт Swift .atomic/Zig AtomicFile (tmp+rename БЕЗ fsync — не durable). Torn-write через mock-ENOSPC → rollback + удаление temp.
  • FFI-граница: путь → NUL-terminated *u8 (c_path reject interior-NUL → InvalidInput); libuv сам конвертит UTF-8/WTF-8 → UTF-16 на Windows → CWStr не нужен на libuv-бэкенде ([M-176-cwstr-direct-winapi]). Данные → (*u8, int). Non-blocking fs_seek (lseek) + platform-predicate — в io_console.h (без libuv).

Реализационные ноты (обход codegen-ограничений; НЕ упрощения семантики)

  1. Fs effect — int-primitive (не rich Result): effect-vtable стирает value-IoError-error в nova_str; обёртки строят IoError/Metadata/DirEntry вне effect-границы.
  2. value-record литералы (Path/OpenOptions/Permissions/FileType): typed-форма (Path { … }) в блок-позиции; anonymous ({ … }) в =>-теле с объявленным возвратным типом (checker: typed-prefix redundant в =>).
  3. std.fs free-fn имена не коллидят с std.io generic-хелперами (coarse-by-name резолв): read_text/write_text/ copy_file (не read_to_string/write_str/copy — те резолвятся в std.io.*[Path] mono).
  4. IoError.path/source (§3b full-shape) — отложены: io↔path module-cycle + value-Option[Path]-mono blast-radius на io-core baseline; kind(NotFound/…) сохранён (все тесты/§8.3 на нём). Followup.

D324 — os: Os effect (env / args / cwd / dirs / process) (Plan 176 Ф.3, 2026-07-06)

Статус: IMPLEMENTED (Ф.3). Модуль std/os. Тот же паттерн, что Fs (D323): тонкий int/str-primitive эффект + pure-Nova обёртки, строящие Option/Result/Path вне effect-границы. Reuse IoError (Q3). Subprocess (Command/Child/spawn) — НЕ здесь: под-план 176.1 (Q5).

Os effect — ТОНКИЙ int/str-primitive слой (§3/§0)

type Os effect {
    arg_count() -> int;  arg_at(i int) -> str                              // argv (arg_at(0) = программа)
    env_get(key []u8) -> Option[str];  env_has(key []u8) -> bool           // значение (raw bytes-as-str) / наличие
    env_set(key []u8, val []u8) -> int;  env_remove(key []u8) -> int       // 0 / -errno
    env_len() -> int;  env_key_at(i int) -> str;  env_val_at(i int) -> str // snapshot-итерация vars
    cwd() -> str;  set_cwd(path []u8) -> int                               // "" = error; 0 / -errno
    temp_dir() -> str;  home_dir() -> str                                  // home "" = none
    exit(code int) -> int;  pid() -> int;  hostname() -> str               // exit: real не возвращается; mock записывает
}
  • Как Fs: string-getter’ы несут raw байты-as-str (пустая строка == недоступно/ошибка), мутаторы → 0 / НЕГАТИВНЫЙ POSIX errno. Rich-типы (Option/Result/Path/EnvVar) строятся в os.nv-обёртках ВНЕ effect-vtable (которая стёрла бы их).
  • byte-first (Q1-прецедент): env-ключи/значения и пути кросят как []u8 (handler NUL-терминирует их для C через os_cstr, зеркало fs c_path); env_get несёт байты verbatim → non-UTF-8 Unix env-значение round-trip’ит лосслесс через env_bytes (Rust var_os-прецедент). str-удобная форма (env) несёт те же байты (Go-модель).

Public API (os.nv, все несут Os)

args() -> []str (argv, [0]=программа); env(key str) -> Option[str] / env_bytes(key []u8) -> Option[[]u8] (перегрузка по арности; unset vs empty различимы); has_env; env(key str, value str) / env_bytes(key []u8, value []u8) / remove_env -> Result[(), IoError]; vars() -> []EnvVar (snapshot, Go os.Environ/Rust env::vars); cwd() -> Result[Path, IoError] / cwd(Path) -> Result[(), IoError]; temp_dir() -> Path; home_dir() -> Option[Path]; exit_process(code int) (flush stdout/stderr + terminate; Go os.Exit/Rust process::exit; имя exit_process — bare exit(code, msg) — язык-builtin D13); pid() -> int; hostname() -> Result[str, IoError].

Триада (плумбинг, мокабельность §1a)

real_os() — нативные хуки nova_rt/os_env.h (getenv/setenv/_putenv_s/getcwd/chdir/getpid/ gethostname/… — non-blocking, header-only static-inline, как io_console.h; НЕ libuv-park/wake — это для реального блокирующего I/O); argv захватывается в main() через nova_os_set_args(argc, argv) (int main(int argc, char** argv) — единственная точка эмиссии, emit_c.rs). mock_os(MockOs) — in-memory env/args/cwd map; exit записывается (did_exit()/exit_code()), НЕ терминирует → наблюдаемо в тесте без убийства харнесса; env-значения хранятся как raw []u8 + str.from_bytes_unchecked (byte-transparent, как real).

Concurrency-контракт (§3c)

env(key, value)/cwd(path) мутируют process-global state → inherently racy (Rust сделал set_var unsafe в 1.84). Nova НЕ делает их unsafe, но документирует single-threaded-mutation контракт: мутировать env/cwd только в setup, до спавна конкурентной работы, читающей их. Чтения (env(key)/args/cwd()) — безопасны.

Реализационные ноты

  1. os зависит от fs (для Path) — не цикл (fs не импортит os; io не импортит ни того ни другого).
  2. exit_process, НЕ exit — bare exit = язык-builtin (D13, -> never, message-bearing abort).
  3. cwd()/hostname() ошибка → IoError.from_os(0, op) (kind Other), а НЕ IoError.of(ErrorKind.Other(0), …): Other(int) — payload-вариант, cross-module литерал-конструкция ловит checker-gap [M-176-xmod-payload-variant-ctor]; from_os/kind_from_errno строят Other ВНУТРИ std.io.
  4. Free-fn имена не коллидят (coarse-by-name резолв, D323-нота #3): приватные хелперы os_cstr/os_wrap_unit (не c_path/wrap_unit — те в std.fs).

Амендмент D324 (2026-07-07) — оп env_get -> Option; публичная поверхность перегрузка по арности

Решение владельца: Два уточнения:

  1. env_get в эффектеOption[str] (вместо str с сентинелём-""). Присутствие ключа (ранее определяемое через env_has снаружи опа) теперь решается ЗДЕСЬ: match os_cstr(key) { Ok(ck) => if unsafe { os_env_has(ck.as_ptr()) } == 1 { Some(unsafe { os_env_get(ck.as_ptr()) }) } else { None }, Err(_) => None }. Осмысление: сентинель-"" не различал KEY= (пусто) и отсутствие ключа; Option явен и безопасен (правило 4 стиля).

  2. Публичная поверхность — перегрузка по арности (канон D117-семьи):

    • env(key str) -> Option[str] — чтение переименование с get_env; env_bytes(key []u8) -> Option[[]u8] с get_env_bytes.
    • env(key str, value str) -> Result[(), IoError] — писание; env_bytes(key []u8, value []u8) -> Result[(), IoError] с set_env_bytes.
    • cwd() -> Result[Path, IoError] — чтение с current_dir; cwd(Path) -> Result[(), IoError] — писание с set_current_dir.
    • has_env, remove_env остаются без изменений.

Реализация: real_os() обновлён в std/os/os.nv; mock_os() и MockOs в std/os/mock.nv возвращают Option[str] из @mem_env_get. Миграция: 7 вызовов get_envenv, 3 get_env_bytesenv_bytes, 8 set_envenv, 1 set_env_bytesenv_bytes, 7 current_dircwd, 2 set_current_dircwd (~28 мест в тестах; пуст в spec_tests/examples). Импорты обновлены.

D357 — Http client transport seam (Plan 178 Ф.2, 2026-07-04)

Решение. HTTP-client — value-types + Nova-логика над тонким байт-seam’ом Http. Триада (module-conventions): type Http effect { send(host str, port int, secure bool, request str) -> Result[str, HttpError] } + real_http() (над Net, std.http.transport) + mock_http() (in-memory, std.http.client).

  • Один hop = один send. request — полностью сериализованные wire-байты (несомы как byte-str через str.from_bytes_unchecked, НЕ []u8[]u8-effect-op erasure, то же обоснование что net byte-surface Ф.0.5); возврат — сырые response-байты. Redirect-loop, auth-strip, keep-alive-решение, chunked-decode, парсинг — Nova-логика клиента (std.http.client/wire.nv,client.nv), НЕ в seam.
  • real_http — effect-over-effect: handler-op body выполняет Net (resolve/connect/write/read); допустимо (эффект перформится при вызове op под активным with Net). CORE = Connection: close + read-to-EOF; secure=true (https) → Err(Tls) (🔴 gate Plan 116).
  • mock_http — data-driven (MockResponse = raw-wire ИЛИ status+headers+body; MockHttp.on(method,path,resp)); диспатч через ТОТ ЖЕ parse_response → chunked/malformed покрыты без сокетов. MockResponse.echo_request_header(name) — детерминированная проверка отправленного (auth-strip).
  • Структура (ревизия §3 плана): nested submodules std.http.client/std.http.transport вместо flat std.http — изолирует std.net+json-зависимости от lean message-model core (и обходит два pre-existing codegen-бага: forward-decl-return-type unit-closure-call в single-CU + handler-closure-env GC-root).
  • Установка mock: канон = inline-handler with Http = effect Http { send(..){ m.reply(request) } } (frame-capture, conservative-GC-safe); MockHttp.build()->Effect[Http] объявлен, но heap-closure-env НЕ GC-rooted ([M-178-mock-handler-gc-trace]).

Амендмент net byte-surfaceD173/D301 (см. Ф.0.5). Gated: timeout←173, decompress←179, typed json[T]←180, https/h2←116.

D360 — HTTP client policies: redirect / auth-strip / status / transfer (Plan 178 Ф.2, 2026-07-04)

Решение (CORE-приземлённое подмножество).

  • Redirect: RedirectPolicy | NoFollow | Limited(int) (default Limited(10)); превышение → Err(HttpError{kind: TooManyRedirects(n)}). GET-ify: 303 и (301/302 на не-GET/HEAD) → метод GET + тело/body-headers сброшены; 307/308 сохраняют метод+тело.
  • Cross-origin auth-strip (Q9, security-инвариант): при hop в другой origin (Url.@origin (scheme,host,port) отличается) — Authorization и Cookie удаляются. pos-тест (cross→strip) + control (same-origin→preserve).
  • Status: 4xx/5xx — валидный Response, НЕ ошибка; конверсия opt-in Response.consume @error_for_status() -> Result[Response, HttpError] (Err(Status(code)) для не-2xx/3xx; CORE материализует+пересобирает Response, error-body дренится).
  • Transfer: Content-Length (identity) + Transfer-Encoding: chunked (decode) — оба; двойной CL / CL+TE → Err(Protocol) (smuggling, RFC 9112 §6.1, через HeaderMap.@content_length). CR/LF/NUL/non-tchar в headers → reject (Ф.1).
  • Body: must-consume (D133/D359); .bytes()/.text()/.drain()/.json()(dynamic JsonValue).

Gated за CORE (маркеры, НЕ упрощения — [M-178-client-policy-surface]): Proxy+CONNECT-tunnel / NO_PROXY-матрица (Q23), SSRF-guard (Q24), cookie-jar (Q10), idempotent-retry + pool-eviction (Q16), live keep-alive-reuse, 1xx-interim loop, TE:trailers, Expect:100-continue, auto-decompress (←179). Conformance-фикстуры d357/d360 отложены (compiler forward-decl баг [M-178-conformance-d357-d360-forwarddecl-bug]) → покрыто nova_tests/http*.

D333 — codec-контракт std/encoding/compress: PURE-codec, byte-first, Result (Plan 179 Ф.1/Ф.3, 2026-07-04)

Статус: IMPLEMENTED (Ф.1 inflate + Ф.3 encode — pure Nova, БЕЗ C; brotli=D337 gated). Модуль-декларация — module encoding.compress (folder-module std/encoding/compress, рядом с json/base64/utf16). Класс-соседи: io-core D322 / fs D323 (byte-surface stdlib) + fallible-канон D325. Нумерация: D333 из reserved-диапазона D333–D339 (Plan 179; README.md §reservation; grep-verify коллизий=0). Прежний план/код-комментарии указывали файл spec/decisions/05-stdlib.md — такого файла нет (аспирация; 05 занят 05-memory.md); блок приземлён к классу-соседям в 04-effects.md, ссылки в коде/тестах синхронизированы (§5).

Что

std/encoding/compressPURE-codec без эффекта: ни I/O, ни effect-триады (нет real_*/mock_* — нечего мокать). Все fallible-операции возвращают Result[T, CompressError]; вход и выход — байты ([]u8), str в кодеке НЕ участвует. Единая форма сигнатур: decode = fn(data, max_output), encode = fn(data, level).

Правило

  • (R1) PURE, no-effect (явное conventions-исключение). Кодек — plain fallible-функции + coder-value, НЕ триада (module-conventions «PURE codec/serde need NO effect»). mock-тест НЕ обязателен. Зеркалит json/base64. Это исключение, а НЕ violation effect-триады (owner sign-off, §9 плана).

  • (R2) Byte-first. Вход/выход строго []u8; str не пересекает границу кодека (вызывающий делает .to_bytes()/str.from_bytes(...) сам). Соответствует net/io byte-surface (D302/D322).

  • (R3) D325-нейминг. Bare-имена без try_: inflate/zlib_decode/gzip_decode/deflate/zlib_encode/gzip_encode. Fallible → Result[T, CompressError] (R1/R2 D325); Fail[E] наружу запрещён (R5 D325). Option — только genuine absence (streaming-EOF через is_done(), см. D335).

  • (R4) Единый структурный CompressError (value-record) + OPEN ErrorKind (sum-type — wildcard-арм обязателен у потребителя):

    export type CompressError value { ro kind ErrorKind, ro offset Option[int] }
    export type ErrorKind
        | InvalidData(str) | UnexpectedEof
        | Checksum { ro kind ChecksumKind, ro expected u32, ro got u32 }
        | BadHeader(str) | Bomb(int) | UnsupportedMethod(str) | TrailingData | Other(str)
    export type ChecksumKind | Crc32 | Adler32 | Isize
    

    Checksum несёт фактические expected/got (диагностика); @to_str() — человекочитаемое описание. offset = байт-позиция во входе (None для framing/checksum).

  • (R5) Одна форма на направление. Decode one-shot: inflate(data []u8, max_output int) / zlib_decode(...) / gzip_decode(...)Result[[]u8, CompressError]. Encode one-shot: deflate(data []u8, level CompressLevel) / zlib_encode(...) / gzip_encode(...)Result[[]u8, CompressError].

  • (R6) CompressLevelvalue-record { priv n u8 }, raw 0..11, интерпретация per-codec: fastest()=1 / default()=6 / best()=sentinel→9 / none()=0 / new(n u8) -> Result[...] (n>11 → InvalidData); deflate 0..9 (10..11InvalidData, «brotli-only» — D337). priv-поле читается только own-методом @raw() (cross-module priv-read через свободную функцию ловит E_FIELD_MODULE_PRIVATE на disk-loaded std-модуле — D220/D281).

  • (R7) Целочисленность. Размеры — int; checksums/ISIZE — u32; ISIZE = (uncompressed_len mod 2^32) (D336). Bit-reader bounds-checked; distance>window → InvalidData. Incomplete-Huffman: единственный distance-code принимается (RFC 1951 §3.2.7, Q13). Trailing-data после BFINAL: raw/zlib strict → TrailingData; gzip lenient (multi-member).

Почему

  1. Кодек не касается среды — навязывать effect-триаду/mock значило бы фиктивный boilerplate; конвенция сама выводит PURE codec из-под mock-mandatory (§9).
  2. Byte-first убирает вопрос «а где кодировка» и совместим с net/io/http-байтовым слоем (потребитель Plan 178 передаёт wire-байты напрямую).
  3. Один CompressError + OPEN ErrorKind композируется (кладётся в Result, мапится), а не дробит обработку по под-форматам; wildcard-арм держит форму расширяемой.

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

  • effect-триада для кодека (Compress effect + real/mock) — нечего мокать, чистая функция; отвергнуто как фиктивный слой.
  • str-API поверх байт — лишняя кодировочная неоднозначность; отвергнуто в пользу []u8.
  • per-формат отдельные error-типы — раздувают match; отвергнуто в пользу единого OPEN ErrorKind.

Связь

  • D325 (fallible Result-everywhere — нейминг/форма), D322/D323 (byte-surface stdlib соседи), D302 (net byte-surface).
  • D334 (bomb-cap decode-инвариант), D335 (streaming coder), D336 (checksum), D337 (brotli C-FFI).
  • D133 (must-consume — только BrotliReader), D215/D228 (value-record CompressLevel), D220/D307 (priv-поле).

Эволюция

  • 2026-07-04: приземлён по факту Ф.1 (inflate/gzip/zlib decode) + Ф.3 (deflate/gzip/zlib encode). Landed-отклонения от плана: (1) module-декларация encoding.compress, не std.encoding.compress; (2) файл-адрес D-блока = 04-effects.md, не аспирационный 05-stdlib.md; (3) encode БЕЗ max_output (§3.5 плана — «выход<входа», bomb на компрессии невозможен), несмотря на упоминание в task-prompt.

D334 — bomb-cap: обязательный max_output decode-инвариант против decompression-DoS (Plan 179 Ф.1, 2026-07-04)

Статус: IMPLEMENTED (Ф.1). §8.0-critical. Инвариант — не опция.

Что

Каждый decode-путь (one-shot + streaming + будущий brotli-FFI) обязан нести max_output. Превышение выхода ИЛИ прогресс-входа (anti-flood) → Err(CompressError{kind: Bomb(limit)}) инкрементально — ДО аллокации сверх лимита, НЕ post-factum. Encode max_output НЕ несёт (см. Почему).

Правило

  • (R1) See-it-in-the-signature. max_output — обязательный параметр каждой decode-сигнатуры (inflate(data, max_output), Inflater.new(max_output), …). Вызов decode без него — compile-error (neg-фикстура inflate(data) без max_output), а не runtime-сюрприз.
  • (R2) Инкрементальная проверка. Cap проверяется на КАЖДЫЙ выходной байт (@emit: if max_output > 0 && total_out >= max_output → Bomb), не после материализации всего выхода. Никакого OOM/hang до отказа.
  • (R3) Граница. output == max_output → ok; первый байт сверх cap → Bomb(limit). total_out — общий счётчик, разделяемый членами multi-member gzip (общий cap across членов).
  • (R4) Anti-flood. Прогресс-вход (100k пустых gzip-членов, гигантский FNAME) капится тем же инвариантом / header-field-длиной → Bomb/InvalidData, а не unbounded-skip.
  • (R5) Escape-hatch. max_output == 0 = «без лимита» (low-level caller-trust). Plan 178 всегда передаёт реальный cap (max_decompressed, 100 MiB) — 0 не используется на HTTP-пути.
  • (R6) Encode без cap. Encode-сигнатура строго fn(data, level) без max_output: выход компрессии ограничен ~размером входа (вход уже в памяти → амплификации/бомбы нет). Осознанное решение §3.5, НЕ пропуск.

Почему

  1. Decompression-bomb (малый вход → гигантский выход) — реальный DoS-вектор; cap в сигнатуре делает защиту невозможной к забыванию (прецедент: Zig window_size_max, Node maxOutputLength, zstd window_size_max).
  2. Инкрементальность (не post-factum) — единственный способ не аллоцировать бомбу до отказа.
  3. Encode симметрии cap не требует by-construction — навязывать его значило бы шум в API без инварианта.

Связь

  • D333 (форма кодека), D335 (streaming — read(max_emit) bounded-per-call как второй bound), D325 (Bomb — вариант CompressError).
  • Plan 178 max_decompressedmax_output (потребитель gate Q12).

D335 — streaming incremental coder: feed/read/finish + SYNC-FLUSH + Plan 178 BodyReader-мост (Plan 179 Ф.1/Ф.3, 2026-07-04)

Статус: IMPLEMENTED (Ф.1 decode-readers + Ф.3 writers). Pure-Nova-кодеры — plain value (НЕ consume); consume только у BrotliReader (D337).

Что

Инкрементальные кодеры-значения поверх того же ядра, что one-shot (→ streaming-по-1-байту == one-shot байт-в-байт by-construction). Decode: Inflater/ZlibReader/GzipReader. Encode: Deflater/ZlibWriter/GzipWriter. Контракт feed/read|flush/finish; окно/bit-leftover/checksum-state сохраняются между вызовами.

Правило

  • (R1) Decode-reader (Inflater образец; ZlibReader/GzipReader оборачивают его + framing):
    export fn Inflater.new(max_output int) -> Inflater          // plain value, НЕ consume (Q6)
    export fn Inflater mut @feed(chunk []u8) -> Result[(), CompressError]
    export fn Inflater mut @read(max_emit int) -> Result[[]u8, CompressError]   // bounded-per-call
    export fn Inflater @is_done() -> bool
    export fn Inflater mut @finish() -> Result[(), CompressError]
    
    • Bounded-per-call: read отдаёт ≤ max_emit байт (anti-single-huge-alloc — второй bound поверх bomb-cap D334).
    • EOF-семантика: пустой результат read = «пока нечего»: is_done()==true → поток завершён (clean EOF); иначе нужен ещё feed. finish пампит до конца, валидирует: незавершённый битстрим (нет BFINAL) → UnexpectedEof; мусор после BFINAL (raw strict) → TrailingData; финальный checksum/ISIZE — здесь.
    • Landed-отклонение (амонд): форма read -> Result[[]u8, _] + is_done() вместо планового read -> Result[Option[[]u8], _] — обход codegen-ограничения по Option[Vec[u8]]; семантика D335 (EOF ≠ need-more) сохранена через is_done().
  • (R2) Encode-writer (Deflater образец):
    export fn Deflater.new(level CompressLevel) -> Deflater      // value, НЕ consume
    export fn Deflater mut @feed(chunk []u8) -> Result[[]u8, CompressError]
    export fn Deflater mut @flush() -> Result[[]u8, CompressError]
    export fn Deflater mut @finish() -> Result[[]u8, CompressError]
    
  • (R3) SYNC-FLUSH. @flush = byte-align + пустой stored-блок (маркер 00 00 FF FF); после flush накопленный выход — decodable-префикс (декодится в ровно скормленный вход). Основа SSE/chunked поверх gzip/deflate (прецеденты Go Writer.Flush, Node Z_SYNC_FLUSH). В decode такие interleaved-маркеры прозрачно глотаются (interop с Go/Node-стриминг-серверами).
  • (R4) Multi-member (gzip). is_done/read→пусто = конец ВСЕХ членов; граница члена в V1 не наблюдаема (single-member opt-out — followup §11). Общий bomb-cap across членов (D334 R3).
  • (R5) Plan 178 BodyReader-мост. BodyReader.@next_chunkfeedread(max_emit) — фиксируется здесь как контракт auto-decompress (Content-Encoding gzip/deflate). Compress НЕ импортирует std.http (glue живёт в real_http, §3.4 плана).

Почему

  1. One-shot строится поверх streaming-ядра → нет двух реализаций и «== one-shot» гарантируется конструктивно.
  2. read(max_emit) — единственный способ обслужить bounded-memory-стриминг (feed 32 KB → распухание под cap с ограниченной резидентной памятью).
  3. SYNC-FLUSH — обязателен для интерактивных потоков (SSE); без него gzip-стриминг буферизует до finish.
  4. Pure-Nova-кодеры не держат внешний ресурс (GC-окно) → plain value, без must-consume долга; только brotli держит C-instance → consume (D337).

Связь

  • D333 (кодек-форма), D334 (bomb-cap — read bounded-per-call второй bound), D337 (BrotliReader consume).
  • D133 (must-consume — контраст: pure-кодеры НЕ consume), D228 (value-record coder), Plan 178 D357/D360 (BodyReader-потребитель).

D336 — checksum-контракт: CRC-32 (gzip) / Adler-32 (zlib) / ISIZE verify-by-default (Plan 179 Ф.1, 2026-07-04)

Статус: IMPLEMENTED (Ф.1). Модуль encoding.compress, файл checksum.nv. CRC-32 промоут из std/_experimental/checksums/crc32.nv (free-function-форма as-is, Q15, owner sign-off 2026-07-03); Adler-32 — NEW.

Что

Целостность декодированного потока проверяется по умолчанию: gzip несёт CRC-32 + ISIZE, zlib — Adler-32. Несовпадение → Err(CompressError{kind: Checksum{kind, expected, got}}). Checksum-функции экспортируются самостоятельно (integrity, PNG, ETag).

Правило

  • (R1) CRC-32 (IEEE 802.3, reversed poly 0xEDB88320) — gzip trailer. crc32(data []u8) -> u32 + incremental crc32_init/crc32_update/crc32_finalize (init 0xFFFFFFFF, finalize XOR 0xFFFFFFFF). Вектор: crc32("123456789".to_bytes()) == 0xCBF43926.
  • (R2) Adler-32 (RFC 1950 §9, mod 65521) — zlib trailer. adler32(data []u8) -> u32 + incremental adler32_init(=1)/adler32_update/adler32_finalize(identity). Вектор: adler32("Wikipedia".to_bytes()) == 0x11E60398.
  • (R3) ISIZE — gzip: (uncompressed_len mod 2^32) == ISIZE (НЕ raw-длина; >4 GiB честно wrap’ается mod 2^32 и НЕ ложно-Checksum).
  • (R4) Verify-by-default. Trailer сверяется при finish/one-shot; mismatch → Checksum{kind: Crc32|Adler32|Isize, expected, got} (несёт фактические значения). ChecksumKind различает источник.
  • (R5) Таблицы CRC — runtime-lazy (crc32_table_value); comptime-const-array — followup §11 (обход, НЕ упрощение семантики).

Почему

  1. Silent-truncate без checksum-verify — реальный класс багов (bit-flip/усечение проходят молча); verify-by-default бьёт «декодировали мусор как валидное».
  2. Checksum{expected, got} несёт значения — диагностируемо (какой байт/сумма разошлись), а не «просто ошибка».
  3. ISIZE mod 2^32 — точная семантика RFC 1952 (иначе >4 GiB ложно-fail).

Связь

  • D333 (Checksum — вариант CompressError), D334 (verify на том же decode-пути).
  • Промоут _experimental/checksums/crc32.nv (Q15); Adler-32 NEW.

D337 — brotli C-FFI-контракт (Plan 179 Ф.2)

LANDED (2026-07-06) — decode (one-shot). [M-179-brotli-vendor-lib] снят: google/brotli v1.2.0 декодер собран (common/ + dec/, MSVC x64 /MT /O2) и вендорен как заголовки + статическая lib (без исходников — стиль libuv): headers compiler-codegen/nova_rt/brotli/include/brotli/{decode,types,port,shared_dictionary}.h, lib compiler-codegen/nova_rt/brotli/lib/libbrotlidec.lib (+ build-cache target/brotli-cache/). brotli_decode(data, max_output) работает на официальных RFC 7932-векторах (tests/testdata/*.compressed), bomb-cap инкрементально поверх FFI, ошибки типизированы. Streaming BrotliReader (R2) — deferred [M-179-brotli-reader-streaming] (см. «Реальность»).

Условная линковка (ключевой факт, аменд §5). libuv — mandatory (линкуется ВСЕГДА, Plan 22 F2). brotli — CONDITIONAL: lib попадает в команду компоновки ТОЛЬКО когда CU реально ВЫЗЫВАЕТ декодер. Механизм — «модуль→библиотека», введён этим планом (libuv не тронут): (1) shim nova_rt/brotli_shim.{h,c} — прототипы всегда #include-аются в nova_rt.h (пустая зависимость), (2) brotli_shim.c компилируется и libbrotlidec.lib линкуется в test_runner.rs build_command лишь если генерённый .c содержит call-site к brotli_decode (не просто определение — std-fn’ы эмитятся даже мёртвыми; детектор фильтрует forward-decl/definition-header), (3) при NOVA_USE_BROTLI shim = реальный декодер, без — feature-gate-заглушки (dec_new→0 → UnsupportedMethod, НЕ link-error, Q11). Доказано: программа с gzip_decode но без brotli → uses_brotli=false → lib НЕ линкуется; brotli/http-br → линкуется.

Что

brotli-decode — C-FFI к libbrotlidec (НЕ pure-Nova V1: 120 KB встроенный словарь + нет в Zig-std → nv-sourcing не feasible; C-FFI by-necessity, как net/libuv). Тонкий Nova-API brotli_decode(data, max_output) + streaming BrotliReader поверх C-instance.

Правило (контракт при материализации)

  • (R1) FFI в ffi.nv, extern "C". Extern-сигнатуры — C-ABI без []u8/GC-типов (raw-ptr+len / out-буфер), per D355 (ex-D282) + координация Plan 174.6 M1 (E_FFI_NON_C_ABI_TYPE; в std нет ни одного extern с []u8 — grep=0).
  • (R2) BrotliReaderconsume value (D133): держит C-instance, @finish/consume освобождает его. Единственный consume-кодер в модуле (pure-Nova-кодеры D335 — plain value). Не-consume BrotliReaderEXPECT_COMPILE_ERROR; double-consume/use-after-consume — тоже compile-error.
  • (R3) Bomb-cap-over-FFI (D334): output-cap инкрементально поверх C-стрима (max_emit-капинг per read). Window ≤16 MiB (lgwin ≤24, фикс-bounded) — output-cap ≠ window-cap (документируется отдельно; критик-gap).
  • (R4) Error-маппинг. brotli_dec_errorCompressError: truncated → UnexpectedEof; malformed → InvalidData(str); превышение cap → Bomb; UnsupportedMethod при отсутствии C-фичи.
  • (R5) Level-резерв. CompressLevel 10..11 зарезервированы за brotli (deflate их отвергает, D333 R6). Encode-brotli — followup §11 (asymmetric, vendor enc/ не тащится в V1).

Почему

  1. brotli requires 120 KB dictionary + сложный decoder — pure-Nova V1 не feasible; C-FFI by-necessity (nv-sourcing даёт .nv где возможно, тяжёлый native → FFI, прецедент libuv).
  2. C-instance — внешний ресурс → consume (D133) обязателен: release-долг виден в типе, no silent leak.
  3. Output-cap ≠ window-cap: lgwin ограничивает окно, но выход всё равно надо капить инкрементально (иначе DoS через большой валидный выход).

Реальность (landed vs deferred, 2026-07-06)

  • brotli_decode(data, max_output) — LANDED. Реализован в std/encoding/compress/brotli.nv поверх стрим-шима: newfeed(весь вход, копируется в шиме) → цикл pull(bounded-budget) с инкрементальным bomb-cap → free на КАЖДОМ пути выхода. Бомба ловится по границе (output == max_output ok, первый байт сверх → Bomb); budget = min(64 KiB, max_output − total + 1) → перебор ≤1 байта, память bounded. Extern-сигнатуры (R1) — nova_brotli_dec_{new,feed,pull,done,needs_input,error,free} с *u8/*mut u8+int (модель fs fs_read/fs_write), НИ ОДНОГО []u8 (grep=0).
  • BrotliReader streaming (R2) — DEFERRED [M-179-brotli-reader-streaming]. C-примитивы шима (feed/pull инкрементально) её поддерживают — это тонкая Nova-обёртка consume-типа. Отложена сознательно: owner-deliverable Ф.2 = brotli_decode, а http-auto-decompress (единственный потребитель br) использует one-shot (симметрично gzip_decode/zlib_decode в finalize_response). НЕ tech-debt-без-плана — followup с rationale; consume-neg-тест приземлится вместе с ней.

Связь

  • D333 (кодек-форма), D334 (bomb-cap-over-FFI), D335 (streaming — контраст consume vs plain value), D282/D355 (ex-D282, extern “C” C-ABI FFI), D133 (must-consume).
  • Plan 174.6 M1 (E_FFI_NON_C_ABI_TYPE), Plan 178 (закрывает br-ветку auto-decompress — LANDED, Content-Encoding: brbrotli_decode; Accept-Encoding дополнен br).