Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ jobs:
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Run cargo audit
# wasmtime advisories ignored — behind optional wasm feature gate.
# wasmtime advisories ignored — behind optional wasm feature gate
# (not built by default). RUSTSEC-2026-0149 (wasmtime-wasi 43,
# high) has no fix in the 43.x line; clearing it needs a wasmtime
# 43->45 major bump, tracked separately.
run: >-
cargo audit
--ignore RUSTSEC-2026-0085
Expand All @@ -217,6 +220,7 @@ jobs:
--ignore RUSTSEC-2026-0096
--ignore RUSTSEC-2026-0103
--ignore RUSTSEC-2026-0104
--ignore RUSTSEC-2026-0149

deny:
# Renamed: skipping advisories until smithy ships an upgraded
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,42 @@

## [Unreleased]

## [0.12.0] — 2026-05-22

Theme: **multi-file feature models**. A product line can now be authored
as a top-level feature model plus per-level sub-models in their own
files — vehicle → powertrain → ECU — each file a valid, independently
solvable model on its own. Minor version: new file kind and new
`rivet variant` input behaviour, no breaking schema or CLI removal.

### Added

- **`feature-model-binding` files** (REQ-083) — a `kind:
feature-model-binding` file with a `compose:` list mounts standalone
sub-model files onto parent features. Each mount declares an explicit,
unique `prefix:` and the sub-model's features are namespaced under it
(`pwt:four-wheel`), mirroring the `externals: prefix:ID` model.
Composition is recursive (a sub-model may itself be a parent) and
resolves to one tree that `solve` / `check` / `explain` / `list`
operate on. `FeatureModel::load_composed` / `FeatureModel::load`.
- Every `rivet variant` command accepts a binding file wherever it
accepts a plain model (`rivet variant list --model binding.yaml`) —
the file's `kind:` selects composition vs. single-file parsing.

### Changed

- The s-expression lexer accepts `:` inside a symbol, so a namespaced
feature reference (`prefix:feature`) lexes as one token — required for
cross-prefix constraints like `(implies car pwt:four-wheel)`.

### Notes

- Composition resolves sub-model files by path **relative to the binding
file, within one repository**; pulling a sub-model from another git
repo is not yet supported (tracked separately). A broken mount —
missing file, unknown or `leaf` mount point, duplicate prefix, cyclic
composition — is a hard error, never a silent skip.

## [0.11.1] — 2026-05-21

Theme: **the Mythos silent-failure hunt**. A `scripts/mythos/`
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.11.1"
version = "0.12.0"
authors = ["PulseEngine <https://github.com/pulseengine>"]
edition = "2024"
license = "Apache-2.0"
Expand Down
93 changes: 93 additions & 0 deletions artifacts/feature-model-composition.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Feature-model composition — multi-file feature models (2026-05-21)
#
# User-requested topic (2026-05-21): author a product line's feature
# tree across multiple files — the top-level model in one file, each
# sub-level (powertrain, ECU, ...) in its own file — and have a binding
# file relate them into one resolved tree. The motivating example:
# "build a car or a motorcycle; the powertrain runs on 4 wheels or 2;
# the ECU further down controls this."
#
# Rivet today: a feature model is strictly one file. `FeatureModel`
# (rivet-core/src/feature_model.rs:58) parses exactly `root`, `features`,
# `constraints`, `attribute-schema` — no `include`, `import`, `extends`.
#
# Design settled with the requester: each file stays a first-class,
# standalone feature model (already solvable per-file today); a new
# `feature-model-binding` file mounts sub-models at parent features with
# explicit mapping + prefix namespacing — the same by-reference model as
# `externals: prefix:ID`. See docs/pure-variants-comparison.md §6 (PV's
# Feature-Model / Family-Model split — the binding is the gluing layer).
#
# Targeted v0.12.0 (new capability — minor version, not a v0.11.1 patch).

artifacts:

- id: REQ-083
type: requirement
title: "Feature models must compose across files — sub-models mounted at parent features via an explicit, prefixed binding"
status: draft
description: |
Rivet feature models are single-file: `FeatureModel`
(rivet-core/src/feature_model.rs:58) parses only `root`,
`features`, `constraints`, `attribute-schema` — there is no
`include`, `import`, or `extends`. A product line therefore
cannot be authored as a top-level tree plus per-level sub-trees
owned by different teams (e.g. vehicle -> powertrain -> ECU).

Design (settled with the requester, 2026-05-21):

- Each feature-model file remains a first-class, standalone
model. `rivet variant solve <sub-model>` already resolves a
complete per-file model with its own root and constraints,
with no parent. The "run each subfeature on its own" property
is preserved, not newly built — sub-models need not always
break down from a top.
- A new `feature-model-binding` file (`kind:
feature-model-binding`) declares composition: which sub-model
file mounts at which parent feature. The mapping is EXPLICIT
(parent feature name -> sub-model file) and the mounted
sub-model's features are PREFIX-namespaced — mirroring the
`externals: prefix:ID` by-reference model — so one sub-model
is reusable under several parents without name collisions.
- On load, Rivet splices each sub-model's tree in at its mount
point and unions the constraint sets into ONE resolved tree
for the solver. The merged tree is what `solve` / `check` /
`explain` operate on; each file still validates independently.
- Parent constraints may reference prefixed child features,
e.g. `(implies car powertrain:four-wheel)`.

This is the Feature-Model / Family-Model layering that
docs/pure-variants-comparison.md §6 describes: the binding layer
is the solution-space glue. Per the F2 silent-failure class, a
broken mount must fail loudly — never a silent skip.

Acceptance:
- `rivet variant solve powertrain.feature.yaml` resolves the
sub-model standalone (own root + constraints, no parent),
exit 0.
- Given a `feature-model-binding` mounting
`powertrain.feature.yaml` at parent feature `powertrain`
with prefix `powertrain`, `rivet variant solve <binding>`
yields a single merged tree and `rivet variant list
<binding>` shows the child features namespaced
(`powertrain:four-wheel`).
- A parent constraint `(implies car powertrain:four-wheel)` is
enforced by the solver and surfaced by `rivet variant
explain`.
- The same sub-model mounted under two parents gets two
distinct prefixes; features do not collide.
- A mount whose target file is missing, or whose declared
prefix collides with another mount, is a hard ERROR with
non-zero exit — never a silent skip.
- Regression tests in rivet-core (`feature_model.rs`) and
rivet-cli (variant CLI integration).
tags: [variant, ple, feature-model, composition, multi-file]
fields:
priority: should
category: functional
baseline: v0.12.0-track
links:
- type: derives-from
target: REQ-042
- type: traces-to
target: REQ-044
50 changes: 50 additions & 0 deletions artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1455,3 +1455,53 @@ artifacts:
created-by: ai-assisted
model: claude-opus-4-7
timestamp: 2026-04-22T20:29:48Z

- id: REQ-084
type: requirement
title: "Verus proof CI must fail loudly when its Nix install fails — a continue-on-error proof job that verifies nothing is a silent failure"
status: draft
description: |
The Verus Proofs CI job (.github/workflows/ci.yml) installs Nix
via DeterminateSystems/nix-installer-action, then runs
`bazel test //verus:rivet_specs_verify`. The job is
`continue-on-error: true` — intentionally soft-gated, because
Verus has an open SMT proof gap (ci.yml lines 555-558).

On the self-hosted runner (`[self-hosted, linux, x64, lean-mem]`,
NoNewPrivileges=true, no sudo) the `nix-installer` binary
escalates via `sudo` and fails; the `Install Nix` step errors and
`Verify Verus specs` is then SKIPPED. Because the job is
`continue-on-error`, the workflow stays green. Net effect: the
Verus proof job has been verifying nothing while reporting no
blocking failure — the F2 silent-failure class. A proof job that
is not proving anything is worse than a red one.

Confirmed 2026-05-21 on PR #311 run 26246893086: the Verus job
step sequence was `failure Install Nix` -> `skipped Verify Verus
specs`.

Pinning the action + `determinate: false` (shipped in v0.11.1)
fixed the Rocq job on ubuntu-latest but cannot fix Verus: no
version of `nix-installer-action` installs Nix without root. The
durable fix is to run the Verus (and Rocq) Bazel/Nix jobs in a
rootless `nixos/nix` container — Nix pre-installed in the image,
no installer, no daemon, no sudo, no unpinned-action drift
surface.

Acceptance:
- The Verus CI job's `Verify Verus specs` step actually
executes — assert it is not `skipped` in the job step list.
- Environment breakage (Nix/toolchain unavailable) makes the
job FAIL visibly, distinct from a soft-gated proof result:
`continue-on-error` must not be able to mask a job that
never ran the verifier.
- A completed Verus job's `verus-test-log` artifact contains a
real Verus solver result.
tags: [ci, verus, silent-failure, f2-family, nix]
fields:
priority: should
category: non-functional
baseline: v0.12.0-track
links:
- type: derives-from
target: FEAT-135
46 changes: 46 additions & 0 deletions docs/feature-model-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,52 @@ and `allowed but unbound` features.
Maps features to the artifacts and source files that implement them.
See [feature-model-bindings.md](feature-model-bindings.md).

## 4. Composing models across files

A large product line can be split across files: a top-level model in one
file, each sub-level (powertrain, ECU, …) in its own file owned by
whichever team owns that level. Every model file remains a valid,
independently-solvable feature model on its own — `rivet variant list
--model powertrain.yaml` works with no parent.

A **`feature-model-binding`** file (REQ-083) declares how the files
compose:

```yaml
kind: feature-model-binding
compose:
- parent: vehicle.yaml # path, relative to this binding file
mount:
powertrain: # a feature in the parent = the mount point
model: powertrain.yaml # the sub-model file
prefix: pwt # prefix for the sub-model's features
- parent: powertrain.yaml # a sub-model may itself be a parent
mount:
ecu-control:
model: ecu.yaml
prefix: ecu
```

On load, each mounted sub-model is spliced into its parent: the
sub-model's features are namespaced under the mount `prefix`
(`pwt:four-wheel`), its root becomes a child of the mount-point feature,
and the constraint sets are unioned into one resolved tree that
`solve` / `check` / `explain` / `list` all operate on.

Rules:

- The **mount point** must be an explicit feature in the parent model
with a non-`leaf` group (`mandatory`, `optional`, `alternative`, `or`)
so the sub-model can attach.
- Every mount **`prefix`** must be unique across the whole composition.
- A parent constraint may reference a prefixed child feature, e.g.
`(implies car pwt:four-wheel)`.
- A broken mount — missing file, unknown or `leaf` mount point, duplicate
prefix, cyclic composition — is a hard error, never a silent skip.

Any `rivet variant` command accepts a binding file wherever it accepts a
plain model: `rivet variant list --model binding.yaml`.

## CLI reference

```sh
Expand Down
36 changes: 9 additions & 27 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4663,9 +4663,7 @@ fn cmd_validate(
// nothing : ordinary project validation.
let (store, graph, variant_scope_name) = match (model_path, variant_path, binding_path) {
(Some(mp), Some(vp), Some(bp)) => {
let model_yaml = std::fs::read_to_string(mp)
.with_context(|| format!("reading feature model {}", mp.display()))?;
let fm = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let fm = rivet_core::feature_model::FeatureModel::load(mp)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let variant_yaml = std::fs::read_to_string(vp)
Expand Down Expand Up @@ -4710,9 +4708,7 @@ fn cmd_validate(
// Model + binding, no variant: validate model/binding consistency
// without resolving a specific variant. Unknown feature names in
// the binding file are reported as errors.
let model_yaml = std::fs::read_to_string(mp)
.with_context(|| format!("reading feature model {}", mp.display()))?;
let fm = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let fm = rivet_core::feature_model::FeatureModel::load(mp)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let binding_yaml = std::fs::read_to_string(bp)
Expand Down Expand Up @@ -11415,9 +11411,7 @@ fn cmd_variant_check(
) -> Result<bool> {
validate_format(format, &["text", "json"])?;

let model_yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let model = rivet_core::feature_model::FeatureModel::load(model_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let variant_yaml = std::fs::read_to_string(variant_path)
Expand Down Expand Up @@ -11479,9 +11473,7 @@ fn cmd_variant_check_all(
) -> Result<bool> {
validate_format(format, &["text", "json"])?;

let model_yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let model = rivet_core::feature_model::FeatureModel::load(model_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let binding_yaml = std::fs::read_to_string(binding_path)
Expand Down Expand Up @@ -11557,9 +11549,7 @@ fn cmd_variant_check_all(
fn cmd_variant_list(model_path: &std::path::Path, format: &str) -> Result<bool> {
validate_format(format, &["text", "json"])?;

let model_yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let model = rivet_core::feature_model::FeatureModel::load(model_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;

if format == "json" {
Expand Down Expand Up @@ -11623,9 +11613,7 @@ fn cmd_variant_solve(
) -> Result<bool> {
validate_format(format, &["text", "json"])?;

let model_yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let model = rivet_core::feature_model::FeatureModel::load(model_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let variant_yaml = std::fs::read_to_string(variant_path)
Expand Down Expand Up @@ -11758,9 +11746,7 @@ fn load_and_solve_variant(
rivet_core::feature_model::FeatureModel,
rivet_core::feature_model::ResolvedVariant,
)> {
let model_yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let model = rivet_core::feature_model::FeatureModel::load(model_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let variant_yaml = std::fs::read_to_string(variant_path)
.with_context(|| format!("reading {}", variant_path.display()))?;
Expand Down Expand Up @@ -12086,9 +12072,7 @@ fn cmd_variant_manifest(
) -> Result<bool> {
validate_format(format, &["text", "json"])?;

let model_yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let model = rivet_core::feature_model::FeatureModel::load(model_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let variant_yaml = std::fs::read_to_string(variant_path)
Expand Down Expand Up @@ -12172,9 +12156,7 @@ fn cmd_variant_matrix(
other => anyhow::bail!("unknown --wrap `{other}`: expected `fragment` or `job`"),
};

let model_yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
let model = rivet_core::feature_model::FeatureModel::from_yaml(&model_yaml)
let model = rivet_core::feature_model::FeatureModel::load(model_path)
.map_err(|e| anyhow::anyhow!("{e}"))?;

let binding_yaml = std::fs::read_to_string(binding_path)
Expand Down
Loading
Loading