Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9079c4f
feat(model): rename node kind project -> module (types, kind strings,…
Yona-Appletree Aug 1, 2026
2386287
refactor: sweep .project tree-path literals to .module
Yona-Appletree Aug 1, 2026
53f2e00
test: move "kind": "Project" fixtures and corpora to "Module"
Yona-Appletree Aug 1, 2026
3c8cbcb
chore(schema): regenerate for the module rename; icon/copy stragglers
Yona-Appletree Aug 1, 2026
a86b238
test: catch escaped-quote fixtures and manifest-kind strings missed b…
Yona-Appletree Aug 1, 2026
f4bc2ba
style: rustfmt after the rename sweeps
Yona-Appletree Aug 1, 2026
fe7426a
feat(model): project/module mitosis — container manifest, format gate…
Yona-Appletree Aug 1, 2026
82bef67
test(app): migrate server/link/studio/fw/cli fixtures to the mitosis …
Yona-Appletree Aug 1, 2026
1d64fc4
feat(studio): manifest-backed settings, rename patches the manifest; …
Yona-Appletree Aug 1, 2026
5a50dbd
feat(model): ModuleDef capability — bindings, exports, channel meta, …
Yona-Appletree Aug 1, 2026
3f1c90e
feat(engine): structural scope on RuntimeNodeEntry; delete dead Bus r…
Yona-Appletree Aug 1, 2026
7909f90
feat(engine): scoped channels — writer-shadowing resolution, suppress…
Yona-Appletree Aug 1, 2026
a0f1b33
feat(engine): real ModuleNode, R7 output interface, root de-specialis…
Yona-Appletree Aug 1, 2026
a0d7e47
docs: scoped-bus engine architecture ADR; R7 implemented note
Yona-Appletree Aug 1, 2026
33dd26f
ci: discard build-regenerated tailwind.css before the baseline auto-c…
Yona-Appletree Aug 1, 2026
30e45bb
Merge remote-tracking branch 'origin/main' into claude/yona-implement…
Yona-Appletree Aug 1, 2026
f7e4767
merge: origin/main (firmware-manifest, xt fixes); rename new NodeKind…
Yona-Appletree Aug 1, 2026
851127a
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Aug 1, 2026
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
5 changes: 5 additions & 0 deletions .github/workflows/pre-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,11 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch --depth=1 origin "$HEAD_REF"
BEFORE_SHA="$(git rev-parse "origin/$HEAD_REF")"
# The dx build regenerates assets/tailwind.css in this working
# tree; it is build output here, and a dirty tracked file aborts
# the branch switch below. Discard it (the branch's own copy is
# what gets committed).
git checkout -- lp-app/lpa-studio-web/assets/tailwind.css || true
# The pull_request checkout is a detached merge commit; the fresh
# captures live in the gitignored story-images/.new/ and survive the
# branch switch in place.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Project/module mitosis: container manifest and the relocated format gate

- Status: accepted
- Date: 2026-08-01
- Context: `docs/design/modules.md` §1/§6 (ratified 2026-07-31);
implementation plan `planning/2026-08-01-1003-modules-impl-roadmap` P2.

## Decision

The authored project root splits into two files ("mitosis"):

- **`project.json` — the container manifest, NOT a node.** Exactly the
workspace identity: `format`, optional `uid`, optional `name` (provenance
joins it in a later phase). No `kind` tag, no node envelope, unknown keys
rejected. Modeled as `lpc_model::ProjectManifest`: read by a streaming
`JsonSyntaxSource` probe, written by a hand-rolled deterministic writer.
It is deliberately **not** a `#[derive(Slotted)]` type — a second
shape+codec surface would link into every firmware image for three
fields, and serde surface is the flash lever.
- **`module.json` — the root module node** (`kind: "Module"`, `nodes`
map). `ModuleDef` loses `format`/`uid`/`name` to the container; carrying
them in a module artifact is now a parse error, so pre-mitosis roots
fail loudly instead of silently dropping identity fields.

**Format gate (settled D-A).** `PROJECT_FORMAT_VERSION` bumps 2 → 3
(bump-and-refuse; v2 schemas snapshotted to `schemas/history/v2/`). The
gate moves to the container: `ProjectRegistry::load_root` reads
`/project.json` through the streaming probe before anything parses — one
code path on host, browser, and device, proven on the emulated-firmware
path. A **missing or malformed container manifest is a hard refuse**,
never a skip: the manifest carries the gate, so the old
skip-on-malformed fallthrough would let unversioned projects load
ungated. Devices receive both files; the deploy path copies the whole
folder, so no firmware-side special case exists.

**Vendored module folders carry no format** (Q10 settled): a module
folder inside a project is gated by the host project's container; the
loader never re-runs the gate for child artifacts. Standalone opening of
a bare module folder wraps it in a workbench project and assumes the
current format (alpha posture).

**Schemas.** `project.schema.json` becomes the closed container schema
(`additionalProperties: false`, `format` const-pinned); a new
`module.schema.json` is the single-variant kind-tagged envelope over
`ModuleDef`; the conformance walk routes by filename.

**Studio.** The library's `package_manifest` rewrites on
`ProjectManifest` (read→modify→write is lossless because the vocabulary
is closed). The gallery rename now also patches the manifest `name` —
post-mitosis the manifest is library-owned workspace metadata, never an
authored def slot, so rename lives where the identity lives. The project
popup's settings rows render read-only from the manifest; the root def
contributes only its `nodes` count. Blank Created packages write a
minimal `module.json` so they stay loadable — gated to blank creates so
device pulls stay byte-faithful and adoption parity hashes cannot
diverge.

## Rejected alternatives

- **Deploy-time-only gating** (validate format when Studio pushes, skip
on device): loses the on-device refusal for projects that arrive by
other means (copied SD contents, partial syncs), and splits the gate
into two implementations. The streaming container probe costs the
device one tiny file read.
- **Slotted container type**: uniform codec machinery, but drags a second
shape and its serializer into every firmware image for three fields.
- **Keeping identity fields on the root module def**: the design's whole
point is that workspace concerns (who/when/what version) are not part
of the module's technical spec; it also kept the "Studio lets you
retype your project's uid" class of bugs structurally possible.

## Consequences

- Pre-mitosis projects (format 2 and earlier) refuse with a clear
format/manifest diagnostic; there is no migration (alpha posture).
- `read_module_format_json`/`ModuleFormatProbe` are gone; the manifest
probe is the single format authority.
- The persisted-state and panel phases (P8+) get a stable, non-authored
home for container-adjacent state (`.lp/`), and P3 adds provenance to
the manifest vocabulary.
83 changes: 83 additions & 0 deletions docs/adr/2026-08-01-scoped-bus-engine-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Scoped-bus engine architecture: structural scopes, scoped resolver keys, module runtime

- Status: accepted
- Date: 2026-08-01
- Context: implements docs/design/modules.md R1–R7 (ratified 2026-07-31);
supersedes the two ADRs on the closed #218 spike branch —
`2026-07-28-scoped-buses.md` and `2026-07-28-effects-are-projects.md`
(branch `claude/composite-effects-planning-f4f51b`, kept as a harvest
source). The spike's *model* survived review; this records how the
restructured implementation differs.
- Plan: `planning/2026-08-01-1003-modules-impl-roadmap` P4–P6.

## Decision

**Scope is structural engine state, not a load-time side table.**
`RuntimeNodeEntry` carries the scope a node inhabits
(`ScopeRef::Module { owner }` / `ScopeRef::Sink { owner, entry }`) plus an
introduces-scope bit, assigned inside `ensure_runtime_spine` — the single
code path both fresh load and `apply_project_changes` run — so an edited
project can never wear different scopes than a reloaded one (pinned by a
load-vs-apply differential test). `Pending`/`Failed` entries carry scope
too (R1: the engine always answers), and payload reattach never touches
it. The spike's `BusScopes` table, built and dropped inside one loader
function, is the rejected alternative.

**The resolver stays scope-dumb; scope arrives as a richer key.**
`QueryKey::Bus` is `{ scope, channel }`: the reading node's scope is part
of the cache and cycle-detection identity, so same-named channels in
different scopes can neither collide in the cache nor fake a cycle. The
host answers "which providers win for a read from this scope"
(`NodeTree::providers_for_bus_read`): a pure outward writer-shadowing walk
(R5) that never descends into a scope — which is what makes sink-scope
no-demand (R2) hold *by construction*. The spike's probe-side filter for
inactive playlist entries is the rejected alternative; the pinned test
asserts a probe read with `include_values` never ticks a sink-scope
producer. The playlist ownership-suppression rule is deleted outright:
entry children publish into their entry's sink scope like any producer.

**Reading-scope rule.** A node reads from the scope it inhabits — except
scope *introducers*, whose bus reads face inward (the root's unscoped
reads are root-scope reads; a module export republishing an inner channel
reads it from the scope the module introduces). Write-side classification
is always the owner's inhabited scope (R4: produces write locally; a
module node resides in its parent's scope).

**A real `ModuleNode` runtime, root included.** Every module-kinded node
wears the mirror runtime (harvested from the spike's `ProjectNode`):
`produce` resolves the introduced scope's `visual.out` and forwards
render/sample dispatch to the producer; no writer renders cleared. The
loader registers the R7 surface: authored bindings (the contention pick),
authored exports, and the automatic `output` → `visual.out` fallback
publish for non-root modules (drop-in embedding). Root is no longer a
placeholder special case — the same runtime attaches on load and on the
apply path's reattach. The runtime is deliberately never feature-gated
(every project has a root module; C6 headroom after: ~244 KB).

**Primary visual is an engine-reported role.** `WireBusChannel` gains
`primary_visual`, decided once in the probe (the root scope's listing of
the vocabulary channel the root mirror reads). Studio and friends consume
the flag; `channel.name == "visual.out"` string tests are dead. The name
comparison survives in exactly one place, engine-side, next to the
vocabulary constant.

**Persisted scope identity.** A scope's stable string is its owner's tree
path; a sink scope keys by the authored playlist entry
(`…/entries[k]`) — stable under sibling reorder (names and keys, never
indices), stable across reattach/reload, and following the entry SLOT
rather than its content, so swapping what an entry plays keeps the
entry's panel state. This string becomes the panel-state key prefix in
the panel phases; it was chosen before any device persists one.

## Consequences

- Depth-2 composition works and is pinned (E5): module publishes and
exports count as writers in resolution like any producer — the spike's
latent `collect_writers` omission cannot recur silently.
- Two fallback writers in one scope (host visual + embedded module's
publish) resolve ambiguous-until-authored — the accepted consequence;
the pick is authorable on the module node (`ModuleDef.bindings`).
- Feedback via one channel in one scope reports as a cycle; chains that
need explicit topology use `node:` refs (E5 note).
- The wire still lists channels flat; the structured `WireScopeRef`
surface and per-scope listings land in the next phase (P7).
35 changes: 30 additions & 5 deletions docs/design/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ no format bump semantics to preserve (alpha). Inventory:
- Studio copy: users "open a project", "add a module", browse "Effects".
- "Plugin" is rejected (connotes third-party binaries / dynamic loading).

> Status: the kind/type rename (`ModuleDef`, `NodeKind::Module`, kind
> strings `Module`/`module`, tree paths `.module`, schemas, corpora,
> wire bump) landed 2026-08-01. The file split (§6) is the next phase.

## 3. Bus rules (normative)

### R1 — Modules introduce scopes, structurally
Expand Down Expand Up @@ -175,6 +179,12 @@ public input is an *invitation*, not an error.
Inputs need no counterpart: consumed inheritance (R5) already lets a host
feed a module's inner consumers with zero authoring (see E6).

> Status: implemented 2026-08-01 (engine C1–C3) — structural scopes,
> scoped resolver keys with writer-shadowing, the module mirror runtime
> (root included), automatic publish + authored exports, and the
> engine-reported primary-visual role. See
> `docs/adr/2026-08-01-scoped-bus-engine-architecture.md`.

### R8 — The panel: one concept, every node, derived from publicity

The **panel** is a first-class per-node concept, not a feature of
Expand Down Expand Up @@ -478,6 +488,11 @@ my-project/
by construction — a module folder's internal wiring is
location-independent.

> Status: the project.json/module.json split, the container-manifest
> format gate (missing manifest = hard refuse, format bumped to 3), and
> the split schemas landed 2026-08-01. `.lp/state.json` arrives with the
> panel phases.

## 7. Bus vocabulary — under discovery

The set of standard channel names (`time`, `visual.out`, `audio.*`, …)
Expand All @@ -492,20 +507,30 @@ Known vocabulary pressure beyond scalars: **touch/gesture sets** (E7 —
multi-point, per-touch identity; shape question tracked as panel.md
P-Q5) and the `phase` convention (panel.md P3).

## 8. Provenance (proposed field set — Q7)
## 8. Provenance (field set settled — Q7)

`author`, `version`, `license`, `created` (ISO date). Optional on any
node and on `project.json`; skip-if-default; no semver semantics yet.
Copy-on-extract per R14.

> Status: landed 2026-08-01 as `ProvenanceDef` (module defs carry it;
> the container manifest carries the same four keys at its top level).
> Copy-on-extract mechanics arrive with the vendoring flows.

## 9. Open questions (G1 redline register)

- **Q3:** → specced as `panel.md` P8/P11 (wire ops, state file shape);
ratify there.
- **Q7:** provenance field set (§8) — enough? (`description`/`homepage`
deferred?)
- **Q10:** bare-module-folder open assumes current format (§6) — fine for
alpha?
- **Q7:** SETTLED (2026-08-01, implementation P3): the §8 four-field set
as proposed (`author`/`version`/`license`/`created`); `description`/
`homepage` deferred until a real need shows up in the registry/import
flow.
- **Q10:** SETTLED (2026-08-01, implementation P2): format is a
container-level concept. A module folder inside a project is gated by
the project's container manifest; the loader never re-runs the gate for
child artifacts (pinned by test). Bare-module-folder standalone opening
keeps the §6 assume-current posture; import-time gating arrives with
the registry/import flow.
- **Q11:** → moved to `panel.md` P6 (merge/tiebreak rules); ratify there.
- **Q12:** → resolved by the latch model: grabbing authored-driven
channels is core behavior (`panel.md` P2/P5), not an increment.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/source-artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ satisfies this):

```json
{
"kind": "Project",
"kind": "Module",
"format": 1,
"name": "example",
"nodes": {
Expand Down
17 changes: 17 additions & 0 deletions examples/basic/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"kind": "Module",
"nodes": {
"clock": {
"ref": "./clock.json"
},
"fixture": {
"ref": "./fixture.json"
},
"output": {
"ref": "./output.json"
},
"shader": {
"ref": "./shader.json"
}
}
}
19 changes: 2 additions & 17 deletions examples/basic/project.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,4 @@
{
"kind": "Project",
"format": 2,
"name": "Basic",
"nodes": {
"clock": {
"ref": "./clock.json"
},
"fixture": {
"ref": "./fixture.json"
},
"output": {
"ref": "./output.json"
},
"shader": {
"ref": "./shader.json"
}
}
"format": 3,
"name": "Basic"
}
20 changes: 20 additions & 0 deletions examples/basic2/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"kind": "Module",
"nodes": {
"clock": {
"ref": "./clock.json"
},
"fixture": {
"ref": "./fixture.json"
},
"output": {
"ref": "./output.json"
},
"shader": {
"ref": "./shader.json"
},
"texture": {
"ref": "./texture.json"
}
}
}
22 changes: 2 additions & 20 deletions examples/basic2/project.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,4 @@
{
"kind": "Project",
"format": 2,
"name": "basic2",
"nodes": {
"clock": {
"ref": "./clock.json"
},
"fixture": {
"ref": "./fixture.json"
},
"output": {
"ref": "./output.json"
},
"shader": {
"ref": "./shader.json"
},
"texture": {
"ref": "./texture.json"
}
}
"format": 3,
"name": "basic2"
}
20 changes: 20 additions & 0 deletions examples/button-playlist/module.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"kind": "Module",
"nodes": {
"button": {
"ref": "./button.json"
},
"clock": {
"ref": "./clock.json"
},
"fixture": {
"ref": "./fixture.json"
},
"output": {
"ref": "./output.json"
},
"playlist": {
"ref": "./playlist.json"
}
}
}
22 changes: 2 additions & 20 deletions examples/button-playlist/project.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,4 @@
{
"kind": "Project",
"format": 2,
"name": "button-playlist",
"nodes": {
"button": {
"ref": "./button.json"
},
"clock": {
"ref": "./clock.json"
},
"fixture": {
"ref": "./fixture.json"
},
"output": {
"ref": "./output.json"
},
"playlist": {
"ref": "./playlist.json"
}
}
"format": 3,
"name": "button-playlist"
}
Loading