Status: Phase 3 complete (2026-04). Kept as the design record;
supersedes the Phase 3 sketch in PLAN.md. See commits tagged
"Phase 3" in git log for the implementation across
workspace_interpreter/ (reference) and the four native ports.
Precedes the reference implementation in workspace_interpreter/
(Step P3.1) and the per-language ports (Steps P3.2–P3.4).
Phase 1 removed 6 hardcoded action arms by making set: schema-driven
and generalizing swap: / pop:. That handled every action whose
effect was a top-level state write.
Phase 3 targets 11 document-mutating actions whose YAML today is
log: placeholder (see AUDIT.md Part A, items 2–18). Examples:
toggle_all_layers_visibility, delete_layer_selection,
new_group, flatten_artwork. These can't be expressed with set:
alone — they traverse the document tree, snapshot for undo, insert /
delete / reparent elements, and iterate over selections.
The choice is whether each of the 11 becomes a new hardcoded primitive
per language (flatten_artwork_primitive:, new_group_primitive:…)
or whether the language gains a small, composable vocabulary that
these 11 and everything else can express in YAML. Phase 1's verdict
on set: was the latter; Phase 3 extends the same thinking to doc
mutation.
The simplest of the 11 and the design's test case. If the YAML below is readable, the design is right; if not, iterate before building.
toggle_all_layers_visibility:
description: >
Toggle all top-level layers between visible and invisible. If any
layer is currently visible, all layers are set to invisible ("Hide
All Layers"). Otherwise all layers are set to preview mode ("Show
All Layers"). Takes one undo snapshot for the whole operation.
category: layers
tier: 3
enabled_when: "state.tab_count > 0"
effects:
- let:
target: >
if any(active_document.top_level_layers,
fun l -> l.visibility != 'invisible')
then 'invisible'
else 'preview'
- snapshot
- foreach:
source: active_document.top_level_layer_paths
as: path
do:
- doc.set:
path: path
fields:
common.visibility: targetReading this aloud: "Bind target to 'invisible' if any top-level
layer is visible, else 'preview'. Snapshot. For each top-level layer
path, write common.visibility = target on that element."
Eleven lines plus the description. One let: binding, one call to a
higher-order function, one snapshot, one foreach over a list of
paths, one doc.set per iteration. No new keyword is specific to
"toggle visibility"; every primitive has a use elsewhere.
Recorded as the answers to the four tollgate questions raised during design. Not revisitable without rewriting the rest of this doc.
Chosen over as: and compute: … as: name. Rationale: let: reads
like the functional-language equivalent it is, and the YAML shape
mirrors set: (map of name → expression). as: remains reserved for
the return value of effects that mutate and return a meaningful
value (pop:, doc.delete_at:, doc.clone_at:); a pure binding is
not that.
- doc.set:
path: path
fields:
common.visibility: invisible
common.locked: falseChosen over nested maps (common: { visibility: invisible, locked: false })
and flat keys with implicit namespacing. Dotted is explicit at the
write site and matches how SCHEMA.md already describes element
fields. Implementation splits the dot, descends into the element, and
schema-validates the leaf.
Lambdas use the syntax already chosen for the expression language:
fun x -> expr (single-arg) or fun (x, y) -> expr (multi-arg). All
four evaluators already parse and evaluate this — the gap is only the
absence of HOFs that take lambdas.
Phase 3 adds four built-in HOFs: any, all, map, filter. Each
takes (list, predicate_or_transform). They cover the ten other
Category C migrations without needing further expression-language
surface in Phase 3.
Paths are a distinct ValueType in the expression language, alongside
Number, Str, Bool, Color, List, Closure. Internal
representation: list of non-negative integers.
Computed properties (property-access syntax, zero-arg, pure):
| Property | Returns | Meaning |
|---|---|---|
p.depth |
number | Number of path segments. [] has depth 0. |
p.parent |
path or null | Drops last index. [].parent is null. |
p.id |
string | Dotted form: [0, 2] → "0.2", [] → "". |
p.indices |
list of number | Escape hatch to list ops. |
Free functions:
| Function | Returns | Meaning |
|---|---|---|
path(i, j, ...) |
path | Constructor from integer args. path() is root. |
path_child(p, i) |
path | Appends index i. |
path_from_id(s) |
path | Parses "0.2" back to a path. Returns null on malformed. |
Equality: p1 == p2 compares index-wise. path(0, 2) == path(0, 2)
is true; path(0, 2) == [0, 2] is false (different types, even though
same internal data).
Rationale for computed-property / free-function split: the existing
expression parser already supports a.b.c property chains but not
a.b(x) method calls. Property accessors with no args map naturally;
operations with args stay as prefix function calls.
Lexical scoping. Binding sites are determined by the text of the program; runtime order is irrelevant.
Every effects: list and every inner do:, then:, else: list
is a scope. When evaluation enters a list, it pushes a new scope
onto the environment chain. When it exits, that scope is popped.
Within a scope, the following introduce bindings:
- let: { name: expr, ... }— evaluates eachexpragainst the current environment and extends the scope withname → value. The new binding is visible from the next effect in the list onward. A secondlet:on the same name does not mutate the first; it opens a nested shadowing scope that extends to the end of the containing list. When that list ends, the outer binding is recovered.- foreach: { source: expr, as: name } do: [...]— evaluatessourceonce in the surrounding scope; for each item, pushes a fresh scope containingname → item, evaluatesdo:, pops. Bindings made inside one iteration are not visible in the next.fun x -> body(inside an expression) — creates a closure value that captures the current environment by value at definition time. When the closure is later applied, argument binding happens on top of the captured environment — not the caller's environment.
A free identifier $x in an expression is resolved by searching the
environment chain outward from the innermost scope until a binding is
found. If no binding exists, the identifier falls through to the
runtime-context namespaces (state, panel, active_document,
theme, etc.) — exactly as today.
This test must pass in all four languages:
effects:
- let: { x: 1 }
- let: { f: "fun _ -> x" } # f captures x from first let's scope
- let: { x: 2 } # new scope shadowing first let
- assert: "x == 2" # direct lookup sees shadowed x
- assert: "f(null) == 1" # closure sees captured x, not shadowed xIf closure semantics are broken, f(null) returns 2 (the shadow
leaked into the capture). Phase 3 CI must include this fixture.
effects:
- let: { captures: [] } # conceptual; pseudo-syntax
- foreach:
source: [1, 2, 3]
as: x
do:
- list_push: { target: captures, value: "fun _ -> x" }
# After the loop, captures[0](null) == 1, captures[1](null) == 2,
# captures[2](null) == 3 — each closure captured its iteration's x.A broken implementation (sharing a mutable cell for x across
iterations, JS-style) returns 3 for all three calls.
Each scope is a persistent map from names to values, linked by parent pointer to the enclosing scope — a frame chain. Pushing a scope is O(1); lookups walk the chain. Closures store a reference to the frame chain at definition time, so lookups from inside a closure use the captured chain, not the caller's.
Python and Swift currently use a flat dict copied on extension
(dict(ctx) / ctx.merging(...)). That's semantically equivalent to
the frame-chain model for correctness (each copy is a persistent
snapshot). Rust and OCaml use an env structure separate from the ctx
JSON. Phase 3 does not require changing these internal shapes — only
adding the let: effect-level hook and ensuring foreach
iterations get fresh frames.
Signatures using the return-value notation from PLAN.md §309. unit
means the effect returns nothing; as: on a unit effect is a warning.
Evaluates each expression against the current environment; the results
extend the current scope for subsequent effects. Multiple bindings in
one let: block evaluate left-to-right (YAML map order) — earlier
names are visible to later expressions in the same block.
- let:
target: "if active_document.any_top_level_layer_visible then 'hidden' else 'visible'"
layer_count: "active_document.top_level_layer_paths.length"Named bindings are visible until the end of the containing list. A
rebinding of the same name in the same effects: list shadows
(see §4.2).
Pushes an undo checkpoint on the active document. No args. Does
nothing if there is no active tab (e.g. state.tab_count == 0).
- snapshotImplementation per-language: calls the existing model.snapshot() /
tab.model.snapshot() / m#snapshot method on the active tab's model.
Evaluates source once; for each item in the resulting list, pushes a
fresh scope with as: bound to the item, runs do: effects, pops.
- foreach:
source: active_document.top_level_layer_paths
as: path
do:
- doc.set:
path: path
fields:
common.visibility: targetSame keyword as the render-time foreach already in use (layers.yaml
breadcrumbs, etc.). The render-time and effect-time forms share the
same scoping discipline but not the same implementation entry point.
Documented as "the foreach keyword has two call sites".
Implicit _index is bound alongside as:, as in render-time foreach.
Schema-driven element write on active_document.at(path). path is a
path value; fields is a map from dotted field paths (relative to the
element root) to expressions. Each field's leaf type is looked up in
the element schema (SCHEMA.md) and the expression result is coerced
as in Phase 1's set:. On coercion failure, the write is skipped and
a diagnostic is emitted; other fields in the same doc.set still
apply (batch-semantics consistent with Phase 1).
- doc.set:
path: path
fields:
common.visibility: target
common.locked: false
name: "'Renamed Layer'"Implementation per-language:
- Resolve
pathagainst the active document, producing a mutable handle to one element. If the path is invalid, emit a diagnostic and skip. - For each
(dotted_field, expr_result)pair:- Descend the dotted path to the leaf, walking through nested
common,stroke, etc. (The allowed descent paths come from the element schema.) - Type-check / coerce the expression result against the leaf's declared type.
- Write. Each leaf assignment goes through the document's normal mutation API so undo / change-notification stays consistent.
- Descend the dotted path to the leaf, walking through nested
Phase 3 deliberately does not introduce:
doc.delete_at,doc.insert_at,doc.insert_after— needed bydelete_layer_selection,new_layer,duplicate_layer_selection,new_group,collect_in_new_layer,flatten_artwork. These primitives are additions to Phase 3's vocabulary, specified in §8 as sub-tollgates before each dependent action migrates.doc.clone_atreturning an element value — needed forduplicate_layer_selection. Requires effect-returns-value machinery.doc.wrap_in_group,doc.unpack_group_at— composite operations; can be expressed as compositions of insert/delete/snapshot once the atomic primitives exist.
The structure is: Phase 3 ships the worked example
(toggle_all_layers_visibility) and the other two pure-visibility-toggle
actions (_outline, _lock) first, using only the primitives in §5.1–§5.4.
Then each subsequent migration in §8 introduces its own primitives under
a sub-tollgate design review.
Four new built-in function names: any, all, map, filter. All
take (list, callable). The callable may be a lambda (fun l -> l.visibility != 'invisible') or a named closure in scope.
| Function | Signature | Returns |
|---|---|---|
any(xs, pred) |
pred: a -> bool |
bool — true iff pred(x) is truthy for at least one x |
all(xs, pred) |
pred: a -> bool |
bool — true iff pred(x) is truthy for every x |
map(xs, f) |
f: a -> b |
list<b> — applies f to each element |
filter(xs, pred) |
pred: a -> bool |
list<a> — keeps elements where pred(x) is truthy |
Behavior on the empty list: any([]) → false; all([]) → true;
map([]) / filter([]) → [].
All four evaluators already have the __apply__ machinery to invoke
a closure by value. The HOFs are a small wrapper over that machinery
plus a list iteration.
Introduces a new ValueType::Path variant alongside the existing ones
(see §3 D4 for the surface API). Port delta per language:
- Python: add
PATHtoValueTypeenum;Value.path(indices: tuple[int, ...])constructor. Update_eval_dot_accessto special-case path-typed receivers. Registerpath,path_child,path_from_idas builtins in_eval_func. UpdateValue.__eq__. - Rust: add
Value::Path(Vec<usize>). Extendeval_dot_accessmatch. Add path functions to thematch namearms ineval_func_call. UpdatePartialEqderive or impl. - Swift: add
.path([Int])toValueenum. Extend property accessor switch. Add to the function dispatch switch. - OCaml: add
Path of int listto thevaluetype. Extendeval_dot_accessmatch. Add toeval_funcmatch.
Paths equate structurally. Paths do not implicitly coerce to
lists; p.indices is the explicit coercion.
Implementation note, not surface feature: each iteration of foreach
must produce a fresh frame. Today's per-iteration ctx extension in
Python (dict(ctx), extended with as: binding, passed to the body)
is correct by construction — each dict(ctx) call is a snapshot,
and closures made inside the body capture that snapshot. The other
three languages must match this semantics.
Test fixture in §4.5 is the contract.
Add path as a declared type. Example on active_document:
active_document:
properties:
top_level_layer_paths:
type: list
item_type: path
description: "Paths of all top-level elements of kind Layer"
top_level_layers:
type: list
item_type: object
description: "Top-level Layer elements as {path, name, visibility, common, ...}"The schema-driven set: validator (from Phase 1) must also recognize
path as a valid item type.
Two new properties read by Phase 3's first three migrations:
| Name | Type | Used by |
|---|---|---|
top_level_layers |
list<object> |
HOF predicates needing layer fields (e.g. l.visibility) |
top_level_layer_paths |
list<path> |
foreach loops that only need the path, not the payload |
Both are computed from the active tab's document.layers filtered to
Element::Layer kind.
top_level_layer_paths is arguably redundant — one could write
map(top_level_layers, fun l -> l.path) — but Phase 3 keeps it as a
convenience for the hot loop in §2. If profiling finds the materialized
object list expensive when only paths are needed, the path-only version
is cheap.
Phase 1 Part B observed active_document.layers_panel_selection_*
rollup properties. Phase 3 prefers HOFs + computed lists over
more rollups. Existing rollups stay for backward compat with
enabled_when: expressions; new rollups are not added.
Eleven actions, migrated in order of primitive requirements. Each group ships with its required sub-tollgate (new primitives listed before the actions that need them).
toggle_all_layers_visibilitytoggle_all_layers_outlinetoggle_all_layers_lock
Shape: let: target, snapshot, foreach over paths, doc.set of
common.* field. Exit criterion: all three migrate with the YAML shape
in §2, pass cross-language tests, and the three arms in each of Rust /
OCaml / Python are deleted. Swift gains working versions.
Introduce:
doc.delete_at: path→ deleted element (effect-returns-value)doc.clone_at: path→ cloned element (same mechanism)doc.insert_at: { parent_path, index, element }→ unitdoc.insert_after: { path, element }→ unit
Plus: effect-level as: return-binding wired to the same env chain
used by let:.
delete_layer_selectionduplicate_layer_selection
Introduce:
doc.create_layer: { name, children?, common? }→ element
Shipped without a separate design doc. doc.create_layer takes a
name (required, string) and returns a new Layer element whose
common defaults to the schema's defaults (visibility: preview,
locked: false, opacity: 1.0). The element flows through
effect-level as: binding and is installed via a follow-up
doc.insert_at. children and common overrides aren't wired yet;
they're only needed by Phase 5+ compositional actions.
new_layernew_groupcollect_in_new_layer
Introduce:
doc.wrap_in_group: { paths }→ unitdoc.unpack_group_at: path→ unitdoc.wrap_in_layer: { paths, name }→ unit (added during Group C)
Shipped without a separate design doc. doc.wrap_in_group and
doc.wrap_in_layer take a paths expression (list of Path values)
and remove the elements at those paths, wrapping them in a new
Group/Layer. wrap_in_layer also takes a name expression;
wrap_in_group leaves the Group unnamed. doc.unpack_group_at
replaces a Group with its children in place.
Swift's typed Document.layers: [Layer] constrains
doc.wrap_in_group to top-level containers only; Rust/OCaml/Python
support nested wrapping via their Element-tree APIs.
flatten_artwork
Originally planned:
- Mechanism for
layer_options_confirmto read its dialog'sprop:values inside an expression.
Resolved without new machinery: the dialog's OK button forwards
dialog state (dialog.name, dialog.lock, dialog.show,
dialog.preview) as dispatch params:. layer_options_confirm then
reads them as param.name / param.lock / etc. — the same namespace
every action already uses for dispatch arguments. A latent bug in the
open_dialog effect handler (outer-action param overlaying the
dialog's own _dialog_params during init evaluation) was fixed by
dropping param from the init eval-ctx extras.
layer_options_confirm
enter_isolation_mode
Already half-done: Phase 1 Step 6 added pop: panel.isolation_stack
for exit_isolation_mode. Enter is a conditional list_push: panel. isolation_stack — primitive may already exist. Worth verifying before
designing a sub-tollgate.
Phase 1 finding: Let / Lambda / Closure are already present in all four expression evaluators (see agent survey, 2026-04-16). Phase 3's non-trivial port work is:
| Area | Python | Rust | Swift | OCaml |
|---|---|---|---|---|
let: effect wired to env |
new | new | new | new |
Effect-time foreach |
new | new | present (render only) | new |
snapshot primitive |
new | new | new | new |
doc.set primitive |
new | new | new | new |
HOFs: any / all / map / filter |
new | new | new | new |
path value type |
new variant | new variant | new variant | new variant |
active_document rollups (§7.2) |
new | new | new | new |
"New" here means ~20–50 lines per item per language. The largest
single item is doc.set — it needs the per-element schema descent
logic, which is per-language mirror of the Phase 1 apply_set_effects.
No refactor of the existing set: path / apply_set_schemadriven
infrastructure is required. Phase 3's doc.set shares the coercion
layer (§5.4) but uses a separate resolution path (element-at-path vs.
global state field).
workspace/tests/phase3/ (new): YAML fixtures of the form
name: "toggle_all_layers_visibility_all_visible"
initial_doc:
layers:
- { kind: Layer, name: "A", visibility: visible }
- { kind: Layer, name: "B", visibility: visible }
action: toggle_all_layers_visibility
params: {}
expected_doc:
layers:
- { kind: Layer, name: "A", visibility: invisible }
- { kind: Layer, name: "B", visibility: invisible }Each of the 4 languages loads these fixtures via its existing test
harness (workspace_interpreter/tests/, jas_dioxus/tests/,
JasSwift/Tests/, jas_ocaml/test/) and verifies that running the
action produces expected_doc.
Originally planned as workspace/tests/phase3_semantics/. In practice,
the semantics contracts (closure capture §4.4, iteration capture §4.5,
HOF round-tripping §6.3) shipped as in-language test suites rather
than a cross-language YAML-fixture harness:
- Python reference:
workspace_interpreter/tests/test_phase3_semantics.py - Rust:
jas_dioxus/src/interpreter/expr_eval.rsandrenderer.rsunit tests. - Swift:
JasSwift/Tests/Interpreter/ExprEvalPhase3Tests.swift. - OCaml:
jas_ocaml/test/interpreter/expr_eval_test.ml.
Each language's suite covers the same semantic contracts with test bodies translated into native test tooling. A YAML fixture harness would give better drift-resistance, but was skipped because each language's expression evaluator already has native fixture harness support and the in-language translations were less friction than adding a new YAML-driven harness to 4 test runners.
runtime_contexts.yaml gains type: path; extend the existing schema
validator tests to check paths can round-trip through set: and
doc.set, that list<path> is valid as an item_type, and that
coercion rejects non-path values for path-typed fields.
Phase 3 exit criteria:
- ✅ All 11 Category C actions have their YAML
log:placeholder replaced with realeffects:blocks built from the primitives in §5 / sub-tollgates. - ✅ Hardcoded arms deleted where they existed (Rust
dispatch_action, OCaml/Pythonpanel_menudispatches). - ✅
workspace/tests/phase3/fixtures pass in all 4 languages. - ✅ Semantics contracts pass in all 4 languages via in-language test suites (see §10.2 — the planned YAML fixture harness was replaced by per-language tests).
- ✅ Closure-capture §4.4 and iteration-capture §4.5 contracts pass in every evaluator.
- ✅
AUDIT.mdreflects Phase 3/4 completion (see "Phase 3 / Phase 4 completion status" section).
Phase 4 (open_layer_options + element_at) is complete. The
active_document.at(path) navigation primitive shipped as the free
function element_at(path) — the expression parser already supports
zero-arg property chains (p.depth, p.parent) but not one-arg
methods (obj.at(x)), so a free function stays consistent with the
existing conventions (path_from_id, path_child).