Effects — Fail, Io, Db, handlers, with-блоки
Решения этой группы определяют центральную абстракцию Nova: алгебраические
эффекты. Любое взаимодействие с внешним миром — эффект; у эффекта есть handler;
handler перехватывается в with-скоупе. Из этой идеи следуют замены
ключевых слов async/throws/unsafe на типы и единый механизм
для тестов, транзакций, undo/redo, capability-режима.
| # | Решение |
|---|---|
| D2 | Эффекты вместо ключевых слов async/throws/unsafe |
| D3 | Синтаксис эффектов: типы между ) и -> |
| D4 | ? для пробрасывания ошибки |
| D11 | Имена эффектов и синтаксис with |
| D12 | Effect erasure и dynamic effects |
| D18 | Эффекты объявляются через protocol, не type |
| D25 | throw и параметризация Fail[E] |
| D28 | Вывод эффектов: private — выводится, public — явно |
| D31 | Handler-лямбда для эффектов с одной операцией |
| D61 | Полная семантика эффектов: effect keyword, handler-литерал, Effect[E], interrupt |
| D62 | Прагматичная семантика эффектов: прямые в сигнатуре, Fail strict, Async ambient, правило effect/protocol |
| D63 | forbid X { body } — capability sandbox |
| D64 | realtime { body } — гарантия не-приостановки |
| D65 | Полная семантика Fail: гибрид Fail[E] / Fail, lookup, prelude RuntimeError и Error |
| D67 | ⚠️ ОТМЕНЕНО → D85: ? оператор (две семантики) |
| D68 | Stateful handlers: через closure capture или @as_handler метод record |
| D85 | Операторы ? и !! — унифицированное поведение для Result и Option, throw-стиль через !! |
| D86 | ?? coalesce-оператор — fallback для Result/Option без Fail |
| D87 | Effect[E, IRT] — параметризация Handler типом interrupt’а |
| D120 | #pure views + axioms + #verify/#trusted handlers |
| D115 | Axiom 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 (всё — эффекты) preserved —unsafe_handleris built-in effect handler internally. User-facing syntax ergonomic (Rust-familiarunsafe { }block).unsafe fn— declares function of unsafe type (caller mustunsafe { ... }wrap call). No effect propagation up the call stack —unsafeencapsulates per fn (canonical Rust pattern). Affected ops: pointer creation/deref/ auto-deref/arith/reverse-cast/ordering-compare/&record.field/callingunsafe fn. See Plan 118 §«unsafe { } block model» и D216 §8-9.⚠️ D2 amend (Plan 118.1.5 closeout, 2026-06-06)
#unsafeattribute 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_uncheckedetc.) обязывают 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
#unsafeattribute tounsafe fnkeyword (type-consistent, per Plan 118.5 TypeRef::Unsafe + Plan 118.1.6*unsafe fnptr 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 errorE_UNSAFE_ATTR_DEPRECATED.⚠️ D2 align (Plan 138.5) — fn-ptr type следует тому же no-prefix правилу, что и data-указатели (D216 §1):
unsafeпишется постфиксом pointee —*unsafe fn(...)(unsafe fn ptr), а не prefixunsafe * 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 | Часы, таймеры, задержки |
Random | RNG |
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}`)
}
Почему
- Невидимое поведение в Java/Python/JS. Любая функция может бросить
что угодно — это не видно по сигнатуре. Checked exceptions Java
получились плохо: не комбинируются с дженериками и лямбдами.
Go-стиль
if err != nil— много шума, легко забыть. - Async-вирус. В Rust/JS/C#
asyncотравляет всю цепочку вызовов черезFuture<T>и обязательныйawait. В Nova suspension — ambient runtime-инфраструктура (D62, D14), без цвета функции и безawait. - AI-first. LLM, читая сигнатуру, знает все побочные действия. В Python/Java/Go этой информации в типе нет — для AI это восстанавливается чтением десятка вызываемых функций.
- Один механизм для всего. Тестирование без моков, транзакции, 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. - D25 —
Fail[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 -> () => ...
Почему
- Граница задана структурой.
)слева,->справа — парсер однозначен без маркеров. - Эффекты — это типы (D2, D11). Применяется единое PascalCase-правило (03-syntax.md → D30).
- Читается слева направо как фраза:
«функция
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
}
Почему
- Заимствовано из Rust/Swift, проверено годами использования.
- Дешевле
try { ... } catch { ... }. Безопаснееif err != nil— нельзя забыть проверку. - Не магия. Полностью разворачивается в существующие конструкции
языка (
match,throw) — никаких специальных правил.
Что отвергнуто
try expr(Swift-style). Слово длиннее, а?уже знаком всем, кто видел Rust/Swift.expr!для force-unwrap. Конфликтует с логическим «не», и panic-семантика противоречит 08-runtime.md → D13 (panic не ловится в коде).?безFailв сигнатуре (с автоматическим выводом). Нарушает правило «public-API явный» (D28). В private может работать через вывод, но даже там удобнее видетьFailявно.
Связь
- D25 —
throwкак операция эффектаFail[E],?разворачивается вthrow. - D2,
D11 —
Failкак обычный эффект. - 03-syntax.md → D19 —
matchсо стрелкой=>(используется в 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-литерал — через keywordhandler(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’а».
Почему
- Один блок тела
with— нет визуальной путаницы между телом handler’а и теломwith-блока. - Несколько эффектов в одном
with— естественно и компактно для тестов:with Logger = test_log, Time = fixed_clock, Random = seeded(42) { run_simulation() } - Handler — обычное значение, не специальная синтаксическая
форма, привязанная к
with. Это упрощает композицию — handler’ы можно хранить в переменных, передавать функциям, держать в map. - Симметрия с record-литералами —
Имя { ... }для значений любых типов, без специальных префиксов. 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; литералы различаются по содержимому. - D25 —
Fail[E]— частный случай этой схемы. - D31 — handler-лямбда (третья форма для эффектов с одной операцией).
- 02-types.md → D42 —
protocolкак структурный контракт; эффекты — это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», не часть системы эффектов.
Почему
- Правильный дефолт. 95% случаев — типизированные пайплайны, для них эффекты в типе очереди — гарантия безопасности.
- Эскейп-хатч есть, но виден.
erase[E]илиDynFn— явные маркеры в коде, понятные при ревью. Компилятор не трогает остальные места. - 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) => ... }(через keywordhandler, см. 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). Не ->.
Почему
typeдля данных,protocolдля поведения — единое правило языка (02-types.md → D42). Эффект — это поведение (набор операций без полей), и логично, чтобы он использовал тот же keyword, что и обычные структурные контракты.- Намерение явно по первому токену. Раньше требовалось смотреть
на содержимое
{...}(поля или методы), чтобы понять, что объявлено. Теперь с keyword видно сразу. - Меньше двусмысленности у LLM. В предыдущей редакции D18 LLM
нужно было запоминать «type с одними методами — это контракт/эффект».
Сейчас правило прямее: «методы →
protocol». - Согласованность с 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 устраняет.
Цена
- Breaking change для всех ранее написанных примеров эффектов.
Все
type Db { query, exec }→protocol Db { query, exec }. Поскольку реализации компилятора нет, цена — обновление спецификации и примеров. - Семантическая зависимость в парсинге литералов сохраняется.
Парсер всё ещё смотрит на содержимое
{...}(двоеточие vs стрелка), чтобы различить record-литерал и handler-литерал. Но keywordprotocolявно говорит, что у этого имени литерал — handler-форма. - 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 → D42 —
protocolkeyword; эффекты — частный случайprotocol, использованного в позиции эффекта. - 03-syntax.md → D19 — стрелка
=>в match-arms, та же что в handler-литералах.
Эволюция
История развода в три шага:
- Первая редакция — два keyword’а:
effect X { ... }для эффектов,type X { ... }для всего остального. - Вторая редакция —
effectотменён, эффекты объявляются черезtype. Различение по контексту использования. Этот шаг убрал лишний keyword, но оставилtypeперегруженным (и данные, и поведение). - Текущая редакция — после 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) — directthrow exprExprKind::Interrupt(Ф.3) —interrupt valвнутри handler-literalCall(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::Othersafety-net preserved - Ф.2 —
infer_expr_typepropagatesneverдляExprKind::Throw/ExprKind::Interrupt/Call(panic|exit|abort|unreachable, ...)+ user fn’ов с return typeTy::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] всего два допустимых
исхода:
interrupt v→ прерывание (with-блок возвращаетv). Аналогtry/catchв Java. Continuation отбрасывается.- Новый
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. Раньше
Fail≡Fail[Error](конкретный record-тип). После D65Fail≡Fail[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, потому что выбор детерминирован сценарием, не вкусом.
throw ≠ panic
throw expr — обычная ошибка через эффект, видна в сигнатуре через
Fail[E]. Перехватывается handler’ом в коде.
panic (08-runtime.md → D13) — аппаратные/
математические сбои (деление на 0, переполнение, OOM, выход за границы
массива) или вызов panic(msg) программистом. Не виден в сигнатуре.
Не ловится в коде — означает смерть текущего fiber’а, ловится
только runtime’ом на границе fiber’а.
Это разные миры:
- «обработать можно и нужно» →
throw+Fail[E] - «обработать никак нельзя, fiber умирает» →
panic
Почему
throw— обычная операция эффекта, не специальная конструкция. Минус один концепт —throwобъясняется через тот же механизм, чтоDb.queryиLogger.log.- Тип ошибки в сигнатуре — AI-first: LLM видит конкретный класс ошибок, не общий «может бросить что-то».
throwизвестно из Java/JS/C#/Swift — AI-friendly без переучивания.- Sum-type для нескольких ошибок — простая композиция handler’ов:
один handler ловит весь sum-type, дальше
matchпо вариантам.
Что отвергнуто
raiseилиerror()вместоthrow.throwизвестно по умолчанию из мейнстримных языков.Failвсегда без параметра (как Java unchecked exceptions или Swiftthrows). Теряется видимость типа ошибки в сигнатуре, ломает 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 → D13 —
throw≠panic.
Цена
- Программист обязан явно описывать тип ошибки в публичных API — дополнительная работа, оправданная видимостью контракта.
- Sum-type для нескольких типов ошибок — небольшой синтаксический налог, оправданный простотой композиции handler’ов.
- Граница
throwvspanicтребует понимания — лечится документацией.
Performance: насколько дорогой throw
Bootstrap-runtime реализация throw msg:
- Vtable indirect call:
_nova_handler_Fail->fail(ctx, msg)— один pointer-load + indirect-call. ~1ns на современном CPU. - Handler-method body — пользовательский Nova-код. Зависит.
longjmpна nearest fail-frame: restore callee-saved regs, sp, pc. ~10-20ns. Без RAII-unwind (D6 GC — нет destructor’ов).- 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 exceptions | 10000-50000ns (stack-trace fill-in + class lookup) |
| C++ exceptions | 1000-10000ns (zero-cost happy path, expensive throw) |
| Rust panic | 1000-10000ns (similar to C++) |
| Go panic | 100-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).
Эффект переименован Throws → Fail в 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)
Компилятор анализирует тело функции:
- Использование операции эффекта (
Db.query,Logger.log) прямо в теле → этот эффект добавляется (обязательный для public). - Каждый
throwилиexpr?→Fail[E]добавляется (всегда транзитивный, см. D65). - Каждый вызов функции с эффектами в чужой сигнатуре →
Failтранзитивно добавляется (strict).- Другие эффекты — warning «не объявленный транзитивный X»,
suppressable через
#allow_transit(X).
- Мутация
@fieldвmut @method(03-syntax.md → D35) — этоmut-метод, не эффект (D62 убралMut).
Public API — почему обязательно явно
- Контракт модуля. Сигнатура — это интерфейс, который другие модули видят. Изменение эффектов = breaking change. Должно быть видно в коде, не выводиться невидимо.
- AI-first. LLM, читая сигнатуру публичной функции, должна видеть все побочные действия. Public API — точка, где «сигнатура = полное описание» работает.
- Документация. Public — это то, что попадает в
nova doc. Эффекты — часть документации, не runtime-деталь. - Случайное расширение. Если 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 — пишет один раз. Гарантии чистоты сохраняются.
Почему
- AI-first компромисс. Внутри модуля программист пишет быстро, на границе модуля LLM (и человек) видит явный контракт.
- Гарантия чистоты сохраняется. Public-функция без эффектов — проверенный факт, можно мемоизировать.
- Шум
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/ приватно), эффект-видимость следует видимости функции.
Цена
- Качество сообщений компилятора при ошибке «private-функция приобрела эффект, public-вызывающий не объявлен» — критично. Программист должен видеть где эффект пришёл, через какую цепочку вызовов.
- Цепные изменения в private — диф не показывает явно, что
эффекты расширились. Тулинг (
--show-effects,@no_effects) компенсирует. - Compile-time стоимость — анализ эффектов транзитивный, увеличивает время компиляции на несколько процентов. Приемлемо.
D31. Handler-лямбда для эффектов с одной операцией
Обновлено D61 и D22-rev (2026-05-10): синтаксис
Fail[E]ушёл вFail[E],protocolдля эффектов ушёл вeffect, handler-литерал получил keywordhandler. Тело 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 |
Random | next() | да (если одна операция) |
Logger (минимальный) | log(msg) | да |
Time | now(), sleep(d) | нет |
Db | query, exec | нет |
Net | get, post, … | нет |
Fs | read, 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
Почему
- Главный win —
Fail[E]обработка. В backend-кодеwith Fail[E] = |err| ... { ... }повторяется в каждой обработке ошибок. Сахар сокращает в 2-3 раза без потери семантики. - «Минимум строк на выходе» — один из центральных принципов Nova (01-philosophy.md → D10).
- Граница сахара чёткая — только в позиции
with EffectName =, только для эффектов с одной операцией. Не превращается в общую SAM-conversion. - Симметрия с 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’а (помимо литерала и переменной).
- D25 —
throwкак операция эффекта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).
Цена
- Парсер чуть сложнее — после
with X =нужно различить handler-лямбду (|...|), handler-литерал (handler-keyword) и переменную. Каждый случай распознаётся по первому токену. - Breaking change при добавлении операции — если эффект расширили, все handler-лямбды для него ломаются с compile error. Это корректное поведение (видимое нарушение контракта), но программисту нужно обновить код в нескольких местах.
- Два способа делать одно и то же. Сахар (
|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
Что
Закрывающий блок системы эффектов. Фиксирует:
type Foo effect { ... }— отдельный keyword для объявления типа эффекта (вместо ранее использовавшегосяprotocol).effect Foo { ... }— keyword для handler-литерала (значения, реализующего эффект).Effect[E]— тип значения handler-литерала, first-class.- Effect-row — неупорядоченное множество, дубликаты запрещены.
return v/ финальное выражение в handler-method — нормальное завершение, значение идёт в caller операции (continuation возобновляется).interrupt v— досрочное завершение всегоwith-блока, новый keyword.- tail-position для
return/interrupt— код после запрещён. Effect[E].op(args)— прямой вызов операции на handler-значении, минуя with-стек.- Тип
with-блока — единый типT, который дают и финальное выражение body, и все handler-method’ы (когда они не делаютinterrupt). - Алгоритм компиляции/интерпретации — пошаговое тех-задание для имплементатора (раздел ниже).
Этот блок закрывает 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 vcontinuation не возобновляется. Значение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 42—42: 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 expr—never(как и операция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-methodopна значении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")— keywordthrowраскрывается в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 }
- Парсер регистрирует тип
Fooкак effect-тип. - Каждая
op(params) effects? -> Rв теле — сигнатура операции. Сохраняется в symbol-table эффектаFoo: имя, типы параметров, row эффектов внутри (опц.), return-тип.
При парсинге effect Foo { handler-methods }
- Парсер ищет
Fooв symbol-table — должен быть effect-тип. Если protocol или другой тип — compile error. - Каждый handler-method
name(params) bodyсопоставляется с операциейFoo.name. Имена операций должны точно совпадать. - Каждая операция эффекта обязана иметь handler-method
(full coverage). Иначе compile error «handler missing operation
name». - Параметры handler-method’а биндятся по позиции к параметрам декларации операции; типы инферируются.
- Возвращается значение типа
Effect[Foo].
При парсинге with EffectName = handler-expr { body }
EffectNameищется в symbol-table — должен быть effect-тип.handler-exprдолжен иметь типEffect[EffectName]. Иначе compile error.- Тип
bodyопределяется по правилам выше (раздел «Тип with-блока»). with X = h1, Y = h2 { body }равно вложенным with’ам:with X = h1 { with Y = h2 { body } }.
При вызове операции EffectName.op(args)
- Type checker:
EffectNameсуществует и это effect.EffectNameприсутствует в effect-row enclosing-функции (или активен в текущем with-скоупе через inference, D28).- Типы
argsсовместимы с декларацией операции.
- Runtime (interpreter / codegen):
- Ищет в handler-стеке handler с тегом
EffectName. Стек просматривается сверху вниз, берётся первый найденный. - Если не найден — runtime panic «no handler for effect
EffectName». - Найденный handler — значение типа
Effect[EffectName]. Из него извлекается handler-methodop. - Управление передаётся в handler-method с биндингом параметров.
- Continuation сохраняется (или, в (II) tail-only, не сохраняется — см. ниже).
- Ищет в handler-стеке handler с тегом
При завершении 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)
h— значение типаEffect[E].opищется среди handler-method’овh. Если нет — compile error.- Handler-method вызывается без push’а handler’а в with-стек.
- 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 один — стек глобальный.
Почему
-
Закрытие зияющего пробела в спеке. До D61 семантика resume, тип
Effect[E], поведение «без resume», запрет для never-операций — фактически использовались в коде, но не были формализованы. Любой имплементатор должен был догадываться. Теперь — пошаговый алгоритм, не требующий гипотез. -
Семантика «как обычный return» снижает порог входа. Программист, видящий handler-литерал впервые, должен понимать его за 30 секунд.
query(q) => real_query(q)— «возвращает значение для query», как обычная функция.interrupt— единственный новый keyword, используется редко, его легко выучить отдельно. -
(II) tail-only достаточна для backend-кода. Реальные handler’ы (Fail, Db, Logger, Time, Random, Cache) укладываются в две формы —
return vилиinterrupt vв tail-position. Полная resume-семантика с кодом-после-resume используется в backtracking и sampling-задачах, которые в Nova-целевой нише редкость. -
Раздельные
effect/protocol— семантически разные контракты (статический dispatch vs lookup в with-стеке). Один keyword для обоих создавал ложное ощущение взаимозаменяемости. -
Effect[E]first-class — нужен для handler-декораторов (orm_decorators.nv), которые выражают audit / soft-delete / replica-routing как обычные функции. Без first-class handler’ов это невозможно сделать без AOP/reflection. -
Прямой
h.op(args)— sugar для частого паттерна, без него декораторы пишутся в 2 раза длиннее через вложенныйwith. -
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для эффектов — раздельный keywordeffectснимает двусмысленность со structural-protocol’ами. -
Финальное выражение без keyword’а как «implicit interrupt» для never-операций — implicit поведение зависит от типа операции, AI-unfriendly. Явный
interruptдля never иreturn/финальное выражение для остальных — однозначно.
Связь
- D2 — концепция эффектов вместо keyword’ов.
- D10 — «всё — эффект» как центральная ставка.
- D11 — синтаксис
with X = h { body }. - D18 — отменено в части
«через protocol»; эффекты теперь через
effect. - D25 —
Fail[E]как эффект. D61 формализует, что Fail-handler используетinterrupt(не resume). - D31 — handler-лямбда
для одно-операционных эффектов. Сохраняется как сахар над
effect X { ... }. - D40 — handler-method body имеет две формы (
=> exprили{ block }), какfn. - D53 —
protocolостаётся для 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.
Цена
-
Sweep по spec и examples. ~30+ файлов содержат
protocolдля эффектов (type Db effect { ... }) — переписать наeffect. Handler-литералы (Db { query(q) => ... }) →effect Db { ... }. Fail-handler’ы и другие, которые не делали resume — добавитьinterruptявно. -
Bootstrap-компилятор требует доработки. Сейчас (на момент D61):
effectkeyword не парсится — пока используетсяprotocol.handlerkeyword не парсится — handler-литерал распознаётся эвристикой поIdent (после{.interruptkeyword не парсится — нет в lexer’е.Effect[E]тип не понимается type checker’ом — это просто dynamic-typed value.- Прямой
h.op(args)не реализован.
-
Линтер
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 принято обратное решение: keywordhandlerотменён, литерал записывается через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
- D143).
Clean break — миграция через sweep одной CL’ой (nova_tests/**,
std/**, examples/**, spec/**). Backwards-compat не сохраняется.
D62. Прагматичная семантика эффектов: прямые в сигнатуре, Fail strict, Async ambient, правило effect/protocol
Что
Финальная ревизия философии эффектов после большой дискуссии о
транзитивности, Async, Mut, и правиле выбора effect/protocol.
Закрывающий блок этой темы.
Четыре связанных решения:
- Прямые эффекты в сигнатуре, не транзитивные. Функция объявляет только те эффекты, чьи операции она использует сама, не через вложенные вызовы.
Failstrict. ЭффектFail[E]обязателен в сигнатуре везде, где может произойти throw — прямойthrow eилиexpr?(который desugar’ится в throw). Транзитивный throw через границы вызовов тоже требуетFailв сигнатуре caller’а. Это исключение из правила «прямые эффекты».Async— ambient capability. Не пишется в сигнатурах, не является частью type system’ы. Fiber-runtime — реализационный механизм под капотом.- Правило выбора
effect/protocolдля программиста — два вопроса. Сознательный выбор; compile-time enforcement = последствие. Mut[T]убран из стандартного набора эффектов. Реальные use-case’ы покрываются специализированными эффектами или локальнымиlet mut.
Это большая ревизия философии. Ослабляется R5.2 «сигнатура = полное описание»: теперь сигнатура показывает только прямые эффекты
- Fail транзитивно. Транзитивные эффекты других типов — лишь warning’ом подсвечиваются. R6 capability-режим ослабляется аналогично.
Правило 1. Прямые эффекты в сигнатуре
Что считается «прямым» использованием
Функция использует эффект прямо (и обязана его декларировать), если в её собственном теле:
- Вызывается операция эффекта:
Db.exec(...),Log.info(...). - Используется keyword-сахар, разворачивающийся в операцию эффекта:
throw e⇒Fail[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? | Подменяется в тесте? |
|---|---|---|
Time | clock | fixed_ms(ms) ✓ — фиксированный момент; mut_clock(start_ms) ✓ — sleep продвигает виртуальное время |
Random | RNG | seeded(seed) ✓ — xoshiro256++ deterministic PRNG |
Db/Net/Fs | соединение/socket/fd | in-memory handler ✓ |
Mem | alloc counter | mock-counter (для leak-тестов) ✓ |
Detach | background supervisor | SyncDetach ✓ |
Blocking | OS-thread pool | mock ✓ |
Async | fiber scheduler | не подменяется (runtime mechanic) — НЕ effect |
Источник test-handler’ов:
std/testing/handlers.nvэкспортируетseeded(seed u64) -> Effect[Random](xoshiro256++ — tier с Go math/rand v2 PCG и RustrandChaCha8),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) | нет | protocol | bound на T в HashMap[K Hash, V] |
Ord | нет | нет | protocol | bound в priority queue, сортировке |
Eq | нет | нет | protocol | bound в множествах |
Iter[T] | нет (конкретный итератор) | нет | protocol | for-in / collect через D58 |
From[T] / Into[T] | нет | нет | protocol | conversion (D73) |
TryFrom[T,E] | нет | нет | protocol | fallible conversion (D77) |
| Resource-capabilities (effects) | ||||
Db | соединение к БД | нет | effect | mock в тестах через with Db = ... |
Net | сокет/HTTP-клиент | нет | effect | recorded responses |
Fs | файловая система | нет | effect | virtual-fs handler |
Time | clock | нет | effect | fixed_ms(...) ✓ (uuid v7, jwt); mut_clock(...) ✓ (rate_limiter, retry, cron — advance via sleep) |
Random | RNG | нет | effect | seeded(...) ✓ — xoshiro256++ (uuid v4, ulid, snowflake, bcrypt) |
Log | logger sink | нет | effect | capture-log в тестах |
Trace | distributed tracer | нет | effect | в-memory trace |
Io | stdout/stderr | нет | effect | mock-stdout |
Cache[K,V] | кэш-провайдер | нет | effect | in-memory mock |
Authn/Authz | identity / capability | нет | effect | fixed-user в тестах |
Idempotency | dedup-store | нет | effect | in-memory mock |
| Continuation-effects | ||||
Fail[E] | error reporter | да (throw → never) | effect | один на язык, особый |
| Resource + instrumental | ||||
Mem | alloc counter | нет | effect (instrumental) | observability, ambient (D26) |
| Не существует в типах | ||||
Async | fiber scheduler | — | runtime mechanic | suspension ambient (D14/D62) |
| GC / region | memory allocator | — | runtime mechanic | implicit (D6) |
Кейсы где границы нечёткие
-
Loggerкак protocol: возможно, если используется черезfn f(log Logger)parameter passing без mock. Но 99% случаев — effect (тесты подменяют). Default —effect. -
ComparevsOrdeffect: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, не эффект).
Почему
-
Прагматизм vs дидактика. Полная транзитивность даёт максимально честные сигнатуры, но в реальном backend-коде эффект-row растёт до 8-10 имён, что тяжело читать. Прямые эффекты + Fail strict — баланс.
-
AI-first сохраняется частично. LLM по сигнатуре всё ещё знает прямое использование функции и полную throw-картину. Транзитивные side-effects через помощь IDE — не трагедия для AI, который и так читает несколько уровней.
-
Async как ambient — единственный разумный выбор. В backend-коде он везде. Если он эффект — он шум. Если ambient — программисту не надо думать. Прецедент: Go.
-
Mut[T] не нужен. Каждый раз когда возникает идея «mut-cell» — правильнее дать ей имя. Generic Mut[T] провоцирует анти-паттерн «безымянное shared state».
-
effect/protocolправило через подмену. Sniff-test «подменяю ли через with в тестах» — практически проверяемый критерий, не философская абстракция. -
R5.2 ослабление обоснованно. Чистая транзитивность в эффектах не существует ни в одном мейнстрим-языке. Nova остаётся впереди других языков (Java, Go, Python) в плане видимости throw + прямых эффектов, но не пытается решить «полную карту через типы», что неподъёмно для production-кода.
Что отвергнуто
- Полная транзитивность всех эффектов — обоснованно для революционной заявки, но громоздко в реальном коде. Принят компромисс «прямые + Fail strict».
..Erow-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, без изменений.
- D11 —
withсинтаксис. - 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 обновлены.
Цена
- Sweep по spec и examples — убрать
Asyncиз всех сигнатур (~30+ мест). Перепроверить что в сигнатурах только прямые эффекты (большинство уже так — реальные функции используют свои эффекты напрямую). - Bootstrap-компилятор: warning для транзитивных эффектов,
strict для Fail. Атрибут
#allow_transitв парсере (опционально). - 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
Что
Закрывающий блок по теме обработки ошибок. Объединяет четыре связанных решения:
- Гибридная параметризация
Fail—Fail[E]типизированный (рекомендуется для public API) иFailбез параметра как сахар дляFail[any](catch-all, quick-and-dirty). - Subtype-aware lookup при throw: точный тип E →
Fail(any) → runtime panic. Match по конкретным вариантам sum — внутри handler’а через обычныйmatch. - Re-throw внутри handler’а через
throw expr— ищется outer handler в стеке. - Prelude-типы для runtime-ошибок: sum-тип
RuntimeErrorс фиксированным набором вариантов + recordError { msg }для пользовательских ошибок с сообщением.
D65 заменяет ранее существовавший unit-маркер type Error в prelude
(D26) на полноценный record. Также формализует
лукап-правило handler’ов для Fail, которое раньше было implicit.
Правило 1. Гибридная параметризация: Fail[E] или Fail ≡ Fail[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, библиотечный код |
Fail ≡ Fail[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 helper | Fail[E] или Fail | ok |
| Quick-and-dirty / scripts / тесты | Fail | ok |
Generic в retry, transaction | Fail[E] через [E] | ok |
| Catch-all logger / supervisor | Fail или 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-case | Compile-time check |
|---|---|---|
Fail[E] | typed business errors | exhaustive match по E |
Fail ≡ Fail[any] | catch-all / supervisor / scripts | runtime 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
errorinterface — единственный тип ошибки, runtime-typed. Прямой аналог NovaFail[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 в стеке:
- Точное совпадение — handler
Fail[E]где E совпадает с типом значения. Если найден — вызывается. - Catch-all — handler
Fail(≡Fail[any]). Если найден — вызывается. - 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-
panicalways-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 declared | Callee может бросать |
|---|---|
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.
- Программист обязан выбрать:
- Объявить
Fail[E']как дополнительный эффект (multi-Fail в row). - Использовать
Fail(any) — поглощает оба. - Обернуть через
.map_err(...)?для конверсии E’ → E. - Локально поймать через
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 уточняет:
Failбез параметра — теперь явно сахар надFail[any], не unit-маркер.- Lookup-правило (точный тип → catch-all → panic) явно зафиксирован.
- Re-throw через
throw errв handler’е явно описан. - Prelude-типы
RuntimeErrorиError— новые, заменяют unit-маркер.
Раздел «Эволюция» D25 апдейтится с указанием на D65.
Почему
-
Гибрид удобства и точности.
Fail[E]для production даёт compile-time exhaustiveness и точный caller-knows-what-to-catch.Fail(any) для quick-and-dirty не заставляет придумывать тип. Один способ был бы крайностью. -
Простой lookup без subtype-magic. Точное совпадение типа — локально проверяемо. Match внутри handler’а покрывает sum-варианты. Не нужно расширять type system’у на subtype-aware lookup.
-
Re-throw позволяет композицию handler’ов. Локальная обработка подмножества + проброс остальных — стандартный pattern, работает через standard effect mechanics.
-
RuntimeErrorsum даёт типизированный set встроенных ошибок. Caller match’ит варианты, добавление новой ветки вRuntimeErrorломает существующие caller’ы (через non-exhaustive match warning). Это фича — программист обновляется консистентно. -
Errorrecord — низкоуровневый escape hatch. Не sum-тип (нечего match’ить, кромеmsg), но удобный для логов и UI.
Что отвергнуто
throws Ekeyword (Java-style) — не нужен, единая записьFail[E]единообразна с другими эффектами. Прецедент вводить второе имя для одного концепта (throws≡Fail) нарушает 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’ов. В Novathrowэто keyword (какreturn),Fail[E]это эффект-тип. Они на разных уровнях:throw— control-flow в теле,Fail[E]— декларация в сигнатуре.
Связь
- D2, D3 — синтаксис effect-row.
- D4 —
?пробрасывание ошибки. - D86 —
??coalesce / fallback. - D11 —
withсинтаксис. - D25 —
throwиFail[E]. D65 уточняетFailбез параметра, lookup, re-throw. - D26 — prelude.
ErrorиRuntimeErrorдобавлены/обновлены. - D31 — handler-лямбда
для одно-операционных эффектов. Работает для
Fail(одна операцияfail). - D53 —
anyкак top-type через пустой protocol; основа дляFail≡Fail[any]. - D54 —
isдля runtime-проверок типа в catch-all handler’еFail(any). - D61 — effect/handler keywords, interrupt. D65 не меняет.
- D62 — Fail strict. D65 уточняет совместимость типов при транзитивности.
Цена
- 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.
- Bootstrap-компилятор:
- Парсер уже принимает
Failбез параметра (как имя эффекта). - Type checker нужно расширить на subtype-aware «Fail (any) поглощает Fail[E]».
- Re-throw в handler’е работает через стандартную effect mechanics.
- Парсер уже принимает
- Prelude в bootstrap’е: добавить
RuntimeErrorsum иErrorrecord. Заменить старый unit-маркерError.
Эволюция
Fail без параметра существовал в D25 как сахар над Fail[Error],
где Error был unit-маркером. Это работало, но Error без полей был
бесполезен. D65 переопределяет:
Errorтеперь record{ msg str }— полезный.Failбез параметра теперь сахар надFail[any](universal).- Lookup с приоритетом «точный тип → catch-all → panic».
Дискуссия привела через несколько итераций:
- Сначала рассматривался
Failstrict-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] по двум причинам:
-
Различие наблюдаемо только при полном type-inference, которого bootstrap не реализует. В runtime/codegen «голый Fail» эрейзится через lookup как catch-all (Правило 2), что эквивалентно
Fail[any]. Production-компилятор может реализовать placeholder-семантику через точную D28-инференс E, но это отдельное расширение, не часть базового D65. -
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. Реализуется на двух уровнях:
- Compile-time: для каждой функции, вызываемой в body, type checker проверяет, что её прямые эффекты не пересекаются с forbid-set. Иначе compile error.
- 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-контекста).
Почему
- Capability sandbox без runtime-only решений. Java SecurityManager — runtime, не compile-time. Compile-time даёт feedback при разработке.
- Симметрия с
with:with X = h { ... }устанавливает handler;forbid X { ... }запрещает. Pair-of-opposites. - Прецедент Effekt language — capability tracking через тип, forbid через ограничение row.
- Использования: плагины, песочницы для 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.
Связь
- D11 —
withсинтаксис. forbid синтаксически близок. - D62 — effect/protocol правило, прямые эффекты.
- D64 —
realtimeдля 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’ев.
- Lexer: keyword
- Спека: 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 — «гарантированно
нет».
Почему
- Inverse-семантика лучше для AI-first. В большинстве кода suspend разрешён (это дефолт). Программист пишет специальный маркер только когда отличается от дефолта. Меньше cognitive load.
- Реальные use-cases: real-time системы, hot loops в backend, lock-критичный код. Не везде, но достаточно часто.
- Прецедент: Erlang has
:hibernatefor non-yielding paths, Rust has#[no_std]for no-allocation, Java has@RealTimeannotations. Nova consolidates через один keyword. - Симметрия с
forbid: оба — runtime-ограничения. forbid для эффектов в типах, realtime для невидимой приостановки.
Что отвергнуто
Asyncкак явный эффект в сигнатурах (D62) — везде в backend-коде шум.@no_suspendатрибут только —realtimeblock более гибкий (зона внутри функции), атрибут это sugar.synckeyword —syncимеет другие коннотации (синхронизация, thread-sync) в других языках.pinnedkeyword — слишком узкое значение (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 → D6 —
region { ... }для 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’а
(синхронное исполнение);
realtimeno-op в bootstrap.
- Lexer: keyword
- 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.
Что
Постфиксный оператор ? имеет две разные семантики в зависимости
от типа выражения:
Result[T, E]—?desugar’ится вmatch+throwчерез эффектFail[E](D4).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 везде. Ранний returnNoneиз функции с-> 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 typeOption[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
Result↔Optionчерез?— отвергнуто (Q-fail-coercion). Программист явно конвертирует через.ok()/.into().
Связь
- D4 —
?для Result + Fail[E]. - D25 —
throwкак операция Fail[E]. - D26 — Option/Result в prelude.
- D62 — Fail strict, транзитивность.
- D65 —
Fail[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’ы со своим состоянием) делаются одним из двух способов:
- Closure capture — state живёт в локальной переменной (или параметре функции-фабрики), handler-method’ы захватывают её через closure. Каноничный «лёгкий» способ.
@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’а или метода. Никаких неявных классов.
selfkeyword внутри handler-method’а. Отвергнуто:@уже определён как «field/method receiver’а» в D35, использование внутри handler-литерала естественно ссылается на внешний receiver.
Связь
- D11 — handler-литерал, основной синтаксис.
- D31 — handler-лямбда для одно-операционных эффектов.
- D35 —
@-методы и@field. - D61 —
handlerkeyword. - D66 —
Selfuniversal (можно использовать в 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 показал реальное использование):
- consume-init
?(D196 form 2):consume X = expr? { body }—?здесь unwrap-маркер init-выражения (разворачиваетResult[T,E] → Tдля biнga), НЕ свободный проброс; codegen эмитит throw через enclosingFail(emit_c.rsin_fail_ctx-ветка сохранена). Форма задокументирована в D196 и остаётся каноном.?внутриdefer-body: governed by D158 (?разрешён, если enclosing fn-sig несётFail[E]или обёрнутwith Fail), НЕ данным правилом.
Что
В Nova два постфиксных оператора для работы с Option[T] и
Result[T, E], выбираемых программистом по стилю обработки:
expr?— return-стиль: «не получилось — обёртка наверх как значение». Локальное продолжение цепочки, без эффектов.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
(!cond → cond.@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 нет эффектов → ошибки текут только значениями, ремаппинг на каждом слое задалбывает; у NovaFail-эффект пробрасывает сам, а ремаппинг типов идёт явно в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'И наличии не-identityFrom[E]наE'.
Связь
- D67 — отменено D85.
- D4 —
?через Fail (отменено вместе с D67). - D25, D65 —
Fail[E]остаётся центральным механизмом throw’а;!!— её краткий синтаксис. - D26 — prelude:
RuntimeNoneErrorдобавлен как unit-тип дляexpr!!наOption. - D13 —
panic/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 ?? fallback — coalesce-оператор: если 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 MissingPortErrorpanic("...")(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? | разворачивает в v | early-return из enclosing fn (D67) | требует Fail[E] если expr это Result |
expr!! | разворачивает в v | throw err (для Option — RuntimeNoneError) (D85) | требует Fail[E] |
expr ?? fb | разворачивает в v | возвращает fb (или throw/panic/return если fb это они) | без эффекта для default-value fallback |
Почему
- Локальная замена ошибки на default — частый паттерн (config,
lookup в map’е, parse с fallback’ом).
?/!!для таких случаев слишком тяжёлы — заставляют завестиFail[E]в сигнатуре только ради того, чтобы тут же его catch’ить. - Пуристая операция — coalesce-оператор не требует эффектной системы. Чистая функция, видна на уровне выражения.
- Fallback может быть любым выражением — включая
throwдля замены типа ошибки, илиreturnдля раннего выхода. Гибкость без накручивания grammar’а. - Прецедент. Swift
??, JS/TS??, RustOption::unwrap_or— узнаваемая convention.
Что отвергнуто
??=null-coalescing assignment (rejected.md) — десахарa ??= e ≡ a = a ?? eломается типами: LHSOption[T], RHST(потому что??разворачивает 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 теперь указывают сюда. - D13 —
panic(...)как 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] (для T ≠ never) — 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использует этот механизм. - D26 —
neverкак 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-D142 | Post-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.rs—OpKind::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— кодировка#pureview → 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 означало «без типа». Семантически существуют три различных
состояния:
| Состояние | Смысл |
|---|---|
Untyped | Binder без аннотации (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.
Правило
-
Throw lowering (Stmt + Expr):
expr: nova_str→ legacyNova_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).
-
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.
-
Dispatch precedence (
nova_throw_typedв effects.h):_nova_handler_Fail_any— erased typed slot (Fail ≡ Fail[any] catch-all D65 правило 1). Если установлен, вызывается с(payload, tid)._nova_handler_Fail— legacy string slot. Вызывается сmsg_repr(typeid name). Handler arm может typed (читает payload через frame) или string (читает msg).- Unwind через fail-frame с typed payload preserved
(
error_user_payloadset’нут на step 0 — до dispatch).
-
D65 правило 3 (re-throw):
_nova_handler_Fail = current->prevswap во время handler-body invocation — корректно работает с typed throws потому чтоnova_throw_typedreuses тот же swap pattern. -
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 removednova_throw_valueplaceholder 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:
-
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-arminterrupt vjump’тся в OUR with-block, не в _nova_interrupt_top (который может быть inner nested). Это architectural fix interrupt-frame routing для cross-effect dispatch.
- новый TLS slot
-
Stdlib migration —
semver_range.parse_versionмигрирован на idiomatic D65 правило 3 form (with Fail[A] = |_e| throw NewErr {...}) после Plan 61 fu#1. Other stdlib usages (retry.nvResult-wrap для last_error capture,http.nv/audit.nvconvert-to-Response patterns) — legitimate patterns, не workaround; задокументировано. -
Generic Result typed Err — (история: Plan 61 fu#3) hybrid через extended
Nova_Resultstruct (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-инстанс (hybridnova_make_Result_Err_typedearly-return удалён);expr!!для non-str Err —nova_throw_typedс реальным payload’ом. typed-Err поля (err_typed_payload/err_typed_type_id) сохранены в схеме mono-типа дляResult[T, str]-кейсов.
-
Per-E TLS slots + per-E vtable — реализовано через preamble splice
/*__PER_E_FAIL_DECLS__*/. Для each E type registered in per_e_fail_types — эмиттится typedefNovaVtable_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_valueplaceholder — УДАЛЁН в 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 через extendedNova_Resultснят, остался лишь как back-compat#define-алиас.
Связь
- D25/D65 — Fail семантика, правила 1-5.
- D85 —
expr!!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 extendedNova_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-linkedNovaErrorChain) для 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): эффект переименован
Cleanup→ResourceTrace(освобождает имяCleanupдля протоколаCleanup[E], ex-Consumable, D314), операцииon_scope_enter/exit→on_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
-
Handler не может
throw— observability должна быть idempotent. Compile errorD185-resourcetrace-handler-throwесли signature handler’аthrow’ит. -
Return type должен быть
()— observability-only. Compile errorD185-resourcetrace-handler-non-unit-return. -
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-ретракт).
Связь
- D80 — effect snapshot для cross-fiber.
- D188 §R5 — scope-stack LIFO.
- Plan 100.5 — FFI bridge.
- Plan 100.8 — performance + tooling.
- Plan 110 Ф.7.
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-метод без@(baremethod()) в 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— protocolmut @m(), impl ro.E_PROTO_IMPL_MUT_FOR_RO— protocol@m()(ro), impl mut.E_PROTO_IMPL_MUT_FOR_CONSUME— protocolconsume @m(), impl mut/ro.E_PROTO_IMPL_CONSUME_FOR_MUT— protocolmut @m(), impl consume.
Enforcement paths:
#impl(P)annotation (D186) — declares-conformance: type-checker matches every method’sreceiver_mutat type-declaration site.- Structural conformance (D58) — at use-site (for-in / generic bound
[T Protocol]).
Сравнение с mainstream
| Язык | Receiver mutability в protocol/trait/interface |
|---|---|
| Rust | trait Iterator { fn next(&mut self) -> Option<Self::Item> } — explicit &mut self |
| Swift | protocol IteratorProtocol { mutating func next() -> Element? } — mutating keyword |
| Go | interface { 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] |
Hash | hash() -> u64 | @hash() -> u64 |
Equal | equals(other Self) -> bool | @equal(other Self) -> bool |
Compare[T] | compare(other Self) -> int | @compare(other Self) -> int |
Clone | clone() -> Self | @clone() -> Self |
Display | fmt(sb StringBuilder) | @display(sb StringBuilder) |
Debug | debug_fmt(sb StringBuilder) | @debug(sb StringBuilder) |
Cleanup[E] | cleanup(...) | consume @cleanup(outcome ScopeOutcome) Fail[E] -> () |
WithExitTimeout | exit_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 amend —
Next[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’ы продолжают выполнение.
- Вызов без
DnsNeteffect в области видимости — 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.
- Pos: mock_dns_net lookup →
nova_tests/plan91_12/net_v2_dns_real_slow.nv— opt-in real DNS test (_slowsuffix,NOVA_SLOW_TESTS=1).assert(r.is_ok())с реальнымlocalhostresolver.
Маркеры (закрыты 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 recvUdpRecvHalfиспользует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, которые я закрываю одним блоком:
- EOF возвращался как
Ok(""). Когда peer закрывал соединение,readвозвращалOk("")— неотличимо от (теоретически возможного) пустого чтения и требовало от каждого вызывающего проверятьdata.len() == 0. Закрытие соединения — это событие, а не данные; ему место вErr-ветке. NetErrorнельзя было напечатать.IoError(str)/InvalidAddr(str)несут строку, но достать её без exhaustivematchнельзя — ошибка, которую нельзя залогировать, бесполезна.@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 мапит -2 →
Err(NetError.Eof), -1 → generic error, >= 0 → Ok(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_str → socket_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) — только задокументированы.
Дополнение — NetError → io.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— прямые соответствия;Eof→UnexpectedEof;Closed→NotConnected;Cancelled→Interrupted;IoError(_)→Other(0);InvalidAddr(_)/InvalidPort→InvalidInput— лоссово, текстовая деталь не переносится).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-ренейм
net2→net, гейтовано на санацию 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).
- второй слой literal-name (
- Д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; текст — явной конверсией с валидацией у пользователя.
Решение
- Один слой FFI. Публичные C-функции —
nova_net_*с C-ABI-сигнатурами (D282 rule 2: скаляры / указатель+длина / out-параметры / код-возврата). НИКАКИХnova_str/NovaRt_*_method_*в транспорте. Nova-типы (TcpStream,SocketAddr, …) и вся логика — в.nvповерхextern "C". - Байтовый транспорт. Транспортные опы: вход
(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], НЕ операции эффекта. - 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. - Без статических слотов. Результат — значением: код-возврата (
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. 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_storage→NovaNetAddrпри запросе адреса (accept/peer/local); (б) sender UDP recv; (в)addrinfo→GC-массивNovaNetAddrв DNS — однимgetaddrinfo-вызовом (libuv уже держит весь список адресов в колбэке, C выделяет GC-массив точногоcount— нет повторного/угадывающего запроса,nova_net_dns_lookup). НЕ в hot-path payload.- Split без дублирующего C-API (упрощение D301). У stream-handle с рождения
раздельные
read_scope/write_scopepark-слоты → 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). - Парковка/пробуждение/отмена — без изменений (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_int→Result. Прецедент: 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(как Ruststr::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-имён. Edgeenv(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 (ветвление).
Почему
Resultбезопасен в 100% операций; bare-throws — нет (для must-consumecloseглотает ошибку → потеря данных, см. Plan 176).- Нет границы «I/O vs scalar» = нет вечного вопроса «а это куда?» (был первым же на snowflake).
- Ошибка-как-значение фундаментальнее, чем как-throw: кладётся в
Vec, мапится, собирается, шлётся в канал; брошенныйFail— control-flow. - Меньше имён на операцию (одно vs до трёх); меньше доков и путаницы «какой звать».
!!уже даёт 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[):std/prelude/core.nv—extern Option@unwrap/Result@unwrapсFail[...]— это сам D85-мост!!, by-design.std/prelude/protocols.nv— protocol-member@cleanup(...) Fail[E](Cleanup-протокол) — user-E, R5-forwarding.std/testing/property.nv—assert_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; прецеденты: RustFromIterator for Result, Goerrors.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) { … }(аналог KotlinrunCatching/ SwiftResult(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-i64nanos, 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/@elapsed— int-based (@nanosvsTimestamp.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-intTime.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).
Почему
- Пять расходящихся зеркал одной схемы (prelude-decl / codegen-hardcode / C-vtable / handler-литералы / закомментированная decl) — правка требовала синхронного изменения 5 мест; единый
.nv-источник убирает дрейф ([feedback-maximize-nv-sourcing] §3; прецедент RuntimeError 78 Ф.2, 172.1 U.1). TimerMetrics— интроспекция timer-runtime (Plan 66 territory), не «время»: держать её вTimeраздувало плумбинг-эффект и заставляло каждый mock-clock-handler стабить read-only счётчики (Q1).- Ф.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.nvmock-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, POSIXgettimeofday-эквивалент на всех платформах);_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.nv—Timestamp.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-зависимость (ZigReleaseFast-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 (зеркало GoaddSec-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_f64→Option. Не молчаливый мусор-cast (Rustmul_f64(NaN)паникует = прецедент).
Границы / честные уступки (Q11/Q16)
Duration= знаковыйi64ns, диапазон ±(2⁶³−1) ns ≈ ±292 года.Timestamp= unix-epoch ns, окно 1677-09-21 .. 2262-04-11 (i64 ±292y, Q16) — контракт задокументирован. ZignanoTimestamp() -> i128не имеет 2262-горизонта; Nova принимает i64 осознанно (i128 ломает Q2 single-i64 scalar-bridge и value-ABI ради горизонта >2262).from_unix_nanos(i64::MAX)+checked_add→None;@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_testsd310) → мис-типизацияT.MAXкак record + CC-FAIL. Фикс — в checker member-const-резолюции (172-зона, owner-gated). Follow-up[M-175-type-const-max-shadows-builtin]. Saturation-границы доступны через internali64_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.
Monotonicnever goes backwards by contract. При кажущемся регрессе часов (later mark < earlier — HW/VM/OS-баг, JDK-6458294):@elapsed_sinceSATURATE-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).Monotonicnon-serializable (process-local; Ф.6 верифицирует отсутствие derive-пути — Q13). - (R2) Clock-source (Q14).
monotonic()читаетuv_hrtime(): LinuxCLOCK_MONOTONIC/ macOSmach_absolute_time(оба suspend-EXCLUDED) / Windows QPC (suspend-поведение платформозависимо). Nova гарантирует только монотонность + non-regression, НЕ suspend-inclusion;sleep_untilчерез сон устройства = unspecified-but-monotonic. Индустрия расходится (ZigInstant=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): конформер несёт СВОЙ плумбинг-эффект
(
File→Fs,TcpStream→TcpNet, консоль→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):
read→Ok(0)= EOF только при непустом буфере; short-read — норма; partial-write легален (write_allloop);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): как Rustio::Error(внутренне boxed) — pointer-sized, дёшево течёт черезResult[T, IoError]в generic-хелперах (by-value record ловитResult[T, ValueRecord]-mono-gap). raw_osauthoritative;kind = kind_from_errno(raw_os)— best-effort projection (общие POSIX errno; редкие →Other(raw), §3b). Per-op error sets (Zig) — considered/REJECTED (Q14): один открытыйErrorKindraw_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] consume— must-consume (D133):@close()(flush + Result); незакрытый = compile-errorD133-not-consumed; double-close = use-after-consume. Нет silent flush-on-drop. Бьёт Gobufio.Flush/ RustDrop-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-ограничений; НЕ упрощения семантики)
- Heap
IoError(см. выше) — by-value record не мономорфизируется в generic-Result. - Хелперы инлайнят циклы (не форвардят один bounded-generic в другой generic-fn — чекер не проносит bound через такой форвард).
BufReader/BufWriterстроятся с ЯВНЫМИ type-args (BufWriter[BytesWriter].new(...)): inference-only конструкция generic-wrapper’а не материализует мономорфизированные методы (иначе — NULL-stub → крах). Followup[M-176-generic-wrapper-mono-inference].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(RustOsStr/Path, Swift-systemFilePath, 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 driveC:\+ UNC\srv\share;C:foo/\fooНЕ absolute),parent/file_name/extension/stem/components/normalize(collapse./.., canonical separator; НЕ резолвит symlinks)/join/with_extension.to_str→Option[str](lossless),display→str(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) НЕ-игнорируема. Бьёт Godefer Close()/RustDrop/Java suppressed/Zigclose()->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](общий с netTcpStream). Fileнесёт СВОЙposи использует positioned read_at/write_at (portable — без непортируемого OS-offset=-1«current position»).OpenOptions(read/write/append/truncate/create/create_new, Q13;append+truncate→InvalidInput; 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/ZigAtomicFile(tmp+rename БЕЗ fsync — не durable). Torn-write через mock-ENOSPC → rollback + удаление temp.- FFI-граница: путь → NUL-terminated
*u8(c_pathreject interior-NUL →InvalidInput); libuv сам конвертит UTF-8/WTF-8 → UTF-16 на Windows → CWStr не нужен на libuv-бэкенде ([M-176-cwstr-direct-winapi]). Данные →(*u8, int). Non-blockingfs_seek(lseek) + platform-predicate — вio_console.h(без libuv).
Реализационные ноты (обход codegen-ограничений; НЕ упрощения семантики)
Fseffect — int-primitive (не richResult): effect-vtable стирает value-IoError-error вnova_str; обёртки строятIoError/Metadata/DirEntryвне effect-границы.- value-record литералы (
Path/OpenOptions/Permissions/FileType): typed-форма (Path { … }) в блок-позиции; anonymous ({ … }) в=>-теле с объявленным возвратным типом (checker: typed-prefix redundant в=>). - 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). 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, зеркало fsc_path);env_getнесёт байты verbatim → non-UTF-8 Unix env-значение round-trip’ит лосслесс черезenv_bytes(Rustvar_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()) — безопасны.
Реализационные ноты
osзависит отfs(дляPath) — не цикл (fsне импортитos;ioне импортит ни того ни другого).exit_process, НЕexit— bareexit= язык-builtin (D13,-> never, message-bearing abort).cwd()/hostname()ошибка →IoError.from_os(0, op)(kindOther), а НЕ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.- 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; публичная поверхность перегрузка по арности
Решение владельца: Два уточнения:
-
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 стиля). -
Публичная поверхность — перегрузка по арности (канон 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_env ← env, 3 get_env_bytes ← env_bytes, 8 set_env ← env, 1 set_env_bytes ← env_bytes, 7 current_dir ← cwd, 2 set_current_dir ← cwd (~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вместо flatstd.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-surface — D173/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)(defaultLimited(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-inResponse.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/compress — PURE-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) + OPENErrorKind(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 | IsizeChecksumнесёт фактические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)
CompressLevel—value-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..11→InvalidData, «brotli-only» — D337).priv-поле читается только own-методом@raw()(cross-modulepriv-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).
Почему
- Кодек не касается среды — навязывать effect-триаду/mock значило бы фиктивный boilerplate; конвенция сама выводит PURE codec из-под mock-mandatory (§9).
- Byte-first убирает вопрос «а где кодировка» и совместим с net/io/http-байтовым слоем (потребитель Plan 178 передаёт wire-байты напрямую).
- Один
CompressError+ OPENErrorKindкомпозируется (кладётся вResult, мапится), а не дробит обработку по под-форматам; wildcard-арм держит форму расширяемой.
Что отвергнуто
- effect-триада для кодека (
Compresseffect +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-recordCompressLevel), 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, НЕ пропуск.
Почему
- Decompression-bomb (малый вход → гигантский выход) — реальный DoS-вектор; cap в сигнатуре делает защиту невозможной к забыванию (прецедент: Zig
window_size_max, NodemaxOutputLength, zstdwindow_size_max). - Инкрементальность (не post-factum) — единственный способ не аллоцировать бомбу до отказа.
- Encode симметрии cap не требует by-construction — навязывать его значило бы шум в API без инварианта.
Связь
- D333 (форма кодека), D335 (streaming —
read(max_emit)bounded-per-call как второй bound), D325 (Bomb— вариантCompressError). - Plan 178
max_decompressed→max_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().
- Bounded-per-call:
- (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 (прецеденты GoWriter.Flush, NodeZ_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_chunk→feed→read(max_emit)— фиксируется здесь как контракт auto-decompress (Content-Encodinggzip/deflate). Compress НЕ импортируетstd.http(glue живёт вreal_http, §3.4 плана).
Почему
- One-shot строится поверх streaming-ядра → нет двух реализаций и «== one-shot» гарантируется конструктивно.
read(max_emit)— единственный способ обслужить bounded-memory-стриминг (feed 32 KB → распухание под cap с ограниченной резидентной памятью).- SYNC-FLUSH — обязателен для интерактивных потоков (SSE); без него gzip-стриминг буферизует до
finish. - Pure-Nova-кодеры не держат внешний ресурс (GC-окно) → plain value, без must-consume долга; только brotli держит C-instance → consume (D337).
Связь
- D333 (кодек-форма), D334 (bomb-cap —
readbounded-per-call второй bound), D337 (BrotliReaderconsume). - 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+ incrementalcrc32_init/crc32_update/crc32_finalize(init0xFFFFFFFF, finalize XOR0xFFFFFFFF). Вектор:crc32("123456789".to_bytes()) == 0xCBF43926. - (R2) Adler-32 (RFC 1950 §9, mod
65521) — zlib trailer.adler32(data []u8) -> u32+ incrementaladler32_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 (обход, НЕ упрощение семантики).
Почему
- Silent-truncate без checksum-verify — реальный класс багов (bit-flip/усечение проходят молча); verify-by-default бьёт «декодировали мусор как валидное».
Checksum{expected, got}несёт значения — диагностируемо (какой байт/сумма разошлись), а не «просто ошибка».- 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): headerscompiler-codegen/nova_rt/brotli/include/brotli/{decode,types,port,shared_dictionary}.h, libcompiler-codegen/nova_rt/brotli/lib/libbrotlidec.lib(+ build-cachetarget/brotli-cache/).brotli_decode(data, max_output)работает на официальных RFC 7932-векторах (tests/testdata/*.compressed), bomb-cap инкрементально поверх FFI, ошибки типизированы. StreamingBrotliReader(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.rsbuild_commandлишь если генерённый.cсодержит call-site кbrotli_decode(не просто определение — std-fn’ы эмитятся даже мёртвыми; детектор фильтрует forward-decl/definition-header), (3) приNOVA_USE_BROTLIshim = реальный декодер, без — 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)
BrotliReader—consume value(D133): держит C-instance,@finish/consume освобождает его. Единственный consume-кодер в модуле (pure-Nova-кодеры D335 — plain value). Не-consumeBrotliReader→EXPECT_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_error→CompressError: truncated →UnexpectedEof; malformed →InvalidData(str); превышение cap →Bomb;UnsupportedMethodпри отсутствии C-фичи. - (R5) Level-резерв.
CompressLevel10..11 зарезервированы за brotli (deflate их отвергает, D333 R6). Encode-brotli — followup §11 (asymmetric, vendorenc/не тащится в V1).
Почему
- brotli requires 120 KB dictionary + сложный decoder — pure-Nova V1 не feasible; C-FFI by-necessity (nv-sourcing даёт .nv где возможно, тяжёлый native → FFI, прецедент libuv).
- C-instance — внешний ресурс →
consume(D133) обязателен: release-долг виден в типе, no silent leak. - Output-cap ≠ window-cap: lgwin ограничивает окно, но выход всё равно надо капить инкрементально (иначе DoS через большой валидный выход).
Реальность (landed vs deferred, 2026-07-06)
brotli_decode(data, max_output)— LANDED. Реализован вstd/encoding/compress/brotli.nvповерх стрим-шима:new→feed(весь вход, копируется в шиме) → циклpull(bounded-budget) с инкрементальным bomb-cap →freeна КАЖДОМ пути выхода. Бомба ловится по границе (output == max_outputok, первый байт сверх →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(модель fsfs_read/fs_write), НИ ОДНОГО[]u8(grep=0).BrotliReaderstreaming (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: br→brotli_decode;Accept-Encodingдополненbr).