This release includes breaking changes for platform teams planning a safe upgrade.
✓ No known CVEs patched in this version
Summary
AI summaryProjects can now own canonical entities, resources, and personas that features reference instead of copy.
Full changelog
Reference-over-copy for project-canonical definitions, plus a round of write/read symmetry fixes, driven by what the Lyriks platform needs from the engine. A project can now own entities, resources, and personas once and have member features REFERENCE them, so the copy-per-feature pattern (and the twin-collapsing heuristics it forces on readers) can go away. The maturity heuristic ships as a callable function so it stops being hand-copied across repos, verify reports per-scenario results machine-readably, and three classes of silently-wrong write are now rejected or rolled back.
Two things in the driving report did NOT reproduce on this engine and needed no fix: a condition-less invariant is already rejected at write time (fixed in 0.10.1, apply_batch covers surface, action, AND feature invariants), and apply_batch dry-run has never used weaker validation than commit — both paths run the same diff-aware gate. What was real is that the gate did not deep-validate SHAPES; that is fixed below.
No breaking changes. The new validation is diff-aware on every write path, so existing malformed data stays editable (pinned by tests); the package keeps main/types rather than an exports map, so every existing subpath import — including the unspaghettit/cli/scenarios path baked into generated scenario adapters — resolves exactly as before. The blocked-action fix was checked against 2155 simulated runs over 133 real features: no verdict changed anywhere, and the only two state differences were the leak being fixed. See "Verified against real data" below.
Added
- A project can own canonical entities, resources, and personas, referenced (not copied) by its features. A feature has to be verifiable in isolation, so every consumer — validator, maturity scorer, simulator, model checker, digest, dashboard — reads
feature.entities/.resources/.personasdirectly. Modeling one domain object across N features therefore meant N copies that drift, double-count in graphs, and force readers to guess which twins are "the same thing". A project now carriesentities[]/resources[]/personas[]with stable ids, and a feature references them viaentityRefs/resourceRefs/personaRefs. References are resolved at the REPOSITORY boundary, so nothing downstream changes:verify,model_check,run_all_scenarios,score_feature, digests, and the dashboard all see a self-contained feature, while the definition is stored exactly once. Saving strips the resolved copies back to refs, and an edit made through a member feature (update_entity,add_entity_field) writes back to the canonical definition — which is what canonical means. New tools:list_project_library,promote_to_project_library,link_project_definition,unlink_project_definition,remove_project_definition, pluslink_project_definition/unlink_project_definitionasapply_batchops. promote_to_project_libraryis the inline → reference migration. It moves one definition into the project library, replaces it with a ref, and collapses identically-named copies in the project's OTHER features into references to the same definition. Copies are compared on content with ids ignored (independently authored twins mint their own ids for the entity AND every nested field), and a copy that has genuinely DIVERGED is left alone and reported inskippedrather than silently discarded. Passdedupe:falseto promote only the one feature's copy.computeFeatureMaturity(feature)is exported from the npm package. Consumers that need the maturity number offline were hand-copying the check weights out ofMaturityScorer, so every engine release could silently drift the two formulas apart.import { computeFeatureMaturity } from 'unspaghettit'now returns{ featureId, featureName, score, maxScore, percentage, criticalIssues, recommendedIssues, breakdown }from the same code the dashboard andverifyuse — pure, synchronous, no I/O, safe air-gapped. Delivered viamain+typespointing at a self-contained bundle (npm run build:lib), deliberately NOT anexportsmap: anexportsmap disables directory-index resolution, which would have brokenunspaghettit/cli/scenarios— the path every generated scenario adapter imports. All existing subpath imports and theunspa/unspa-mcpbins resolve exactly as before (verified against a packed tarball in a clean consumer). Deliberately NOT a TRL number: the engine has no TRL concept, and inventing a 1-9 ladder here would just be a second formula to keep in sync — mappercentageandbreakdownto whatever scale you publish.get_digestalso carries amaturity[]block now, so a digest consumer needs no second call.verifyreports per-scenario results, not just a count. Each feature verdict carriesscenarios[]: one row per authored scenario withscenarioId, name,surfaceId/actionId/actionName,passed, expected vs actual status, assertion counts (evaluated / failed / skipped),firstFailingStepand the action it invoked, and a one-linereason. Passing scenarios are included, because a report that lists only failures cannot answer "was this criterion actually exercised?". This is what lets a consumer trace acceptance criteria → scenarios → results without parsing prose.- The dashboard shows which release it is running. A muted
Unspaghettit v0.14.0stamp sits at the bottom-left of the projects container, so a screenshot or a bug report carries its own build identity instead of needing "which version are you on?" as a first reply. The string is inlined frompackage.jsonat build time (Vitedefine), so it costs no fetch and keepspackage.jsonout of the browser bundle. The update banner still owns the "a newer one exists" message; this is just the "which one am I on" one.
Fixed
- A blocked action no longer commits state. A parameter with
bindToStatePathis written into state BEFORE rules run (deliberately, so a rule can gate on the incoming value), but the write survived ablock_action— a validation rule blocking "amount exceeds balance" still lefttransfer.amountset to the illegal value. The same held for aset_statefired by a rule (or an action effect) that ran before the one that blocked, leaving half-applied state. A blocked run now rewinds to the pre-action snapshot: no bind, no effect, nothing commits. Messages, emitted events, the recorded transition, block reasons, and the applied-effect audit trail are all kept — they are the signal that the attempt happened — andonBlockedEffectsstill fire against the rewound state. Invariant violations are not rewound, so the model checker still gets its counterexample state. This makes the platform's workaround (guarding invariant-backing parameters withvalidationspurely to stop the leak) unnecessary. - Tag values keep their casing byte-for-byte. Every write path lowercased tag types and values, so a consumer matching
core:/ family / phase values exactly (the Lyriks platform keys its Features projection off them) saw its projection silently empty out on any write that touched a tagged feature. Tags are now stored as authored, trimmed only. Identity stays case-insensitive — "Growth" and "growth" are still one tag for dedupe, lookup, filtering, and rename — and on a differently-cased re-add the FIRST spelling wins rather than the last. Display no longer down-cases a fragment that already carries a capital, so "MCP" renders as "MCP" rather than "Mcp". Existing lowercase tags are untouched; nothing is rewritten on disk. - Summary lines read as sentences and the scope pickers say what they pick. Every digest line rendered its label glued to its text ("Global Search:Header search over the entire model"): the separator was a whitespace-only template node that the compiler trimmed, so it never reached the page. The separator is now emitted as an expression and every line reads "Label: text". The Summary toolbar's dropdowns also carry visible "Feature" / "Surface" / "Action" labels: with two or three unlabeled selects side by side (and features and surfaces that can share a name), nothing said which level of the model each one narrowed.
invoke_operationeffects pass validation, and their references are finally checked. The effect type shipped in the write path and the simulator while reference-integrity validation checked against a hand-copied list that omitted it, so a legitimately authored boundary call was rejected on the way back in — the same write/read asymmetry this release is closing, in the engine's own validator. All three copies of the effect vocabulary (reference integrity, the shape pass, the simulator's error message) now read fromALL_EFFECT_TYPESso the list cannot drift again. Because the effect was unreachable, the references it names had never been validated either:dependencyIdnow has to resolve to a declaredDependency,operationhas to be one that dependency declares, andresultPathanswers to the same scope rules as aset_statetarget (declared on or shared into the surface, never a derived path). This can only widen what is accepted — aninvoke_operationcould not previously pass validation at all — so nothing that used to work can start failing.
Changed
- Validation now rejects malformed rule / invariant / effect / scenario SHAPES.
apply_batchops are an untyped record, so nothing checked the vocabulary a condition actually used: an unknown operator (gte,>=) fell through the evaluator tofalse, a binary operator with norightcompared againstundefined,{kind:"all", conditions:[]}was vacuously true, and an unknown compositekindwas silently treated as a leaf. Each turned a guard the author wrote into one that could never fire, whiledryRunreportedvalid:true. The write gate now deep-validates: operators against the runtime vocabulary, condition-tree node shapes (composites, quantifiers, leaves), per-type required effect fields, outcome kinds, and scenarioexpectedStatus/ assertion / step shapes. Dry-run and commit run exactly the same checks. This is not a breaking change for existing stores: every write path gates onintroducedValidationErrors(diff-aware), never onvalidateFeaturedirectly, so a pre-existing malformed element blocks neither unrelated edits nor edits to the offending element itself — which is what keeps a spec repairable. Only NEW breakage is refused, and a brand-new feature must be clean. Tests pin all four of those properties. What does change is visibility:verify/score_feature/get_spec_gapsnow report these, so expect a one-time crop of findings on an older store. Rununspa checkto see them; each error names the field and the valid values. - A dangling project-library reference is a per-feature error, never a lost feature. A
entityRefs/resourceRefs/personaRefsentry the owning project cannot satisfy (the definition was removed, or the feature belongs to no project) is reported by validation with the fix spelled out — re-add it,unlink_project_definition, oradd_feature_to_project. The feature still loads and still appears inlist_features, andlist_project_libraryreports every dangling ref with the feature that holds it. Degrading one feature is always preferable to failing a query batch. - Snapshot shape:
project.entities/.resources/.personasandfeature.entityRefs/.resourceRefs/.personaRefsare new, all optional and additive. A snapshot written by 0.14.0 that uses NO references is byte-identical in shape to one written by 0.13.0, so upgrading alone rewrites nothing. The one-way door is per-project and opt-in: once you runpromote_to_project_library, that project is no longer readable by an older engine (0.13 ignores the refs, so the promoted definitions simply vanish from the feature). Malformed ref arrays are dropped on read rather than reaching the resolver.
Verified against real data
The blocked-action rollback is a semantic change, so it was measured rather than argued. Both engines (0.13 and 0.14) were run over 133 real feature snapshots — every action, from its default snapshot and from each authored scenario's starting state — and their results diffed: 2155 simulated runs, 1350 of which blocked, 0 status changes, 0 verdict changes, and exactly 2 differences in resulting state. Both were the leak being fixed: one action wrote a rejected fileName / byteLength into state despite a validation rule blocking with "a source file is already stored", and one applied gravity to a player who was blocked out of the world. No sample or seed feature relies on state landing before a block.
Weekly OSS security release digest.
The CVE patches and breaking changes that affected production tools this week. One email, every Sunday.
No spam, unsubscribe anytime.
Share this release
About Unspaghettit
All releases →Beta — feedback welcome: [email protected]