Scenes loader

mix --serve scenes.mix --name scenes owns file-backed scene lifecycles. It starts with no enabled scenes and reports needs_setup:true until scenes are explicitly enabled. From Stage B it owns the bottom panel, launcher, calendar and notes pages once they are seeded and enabled, replacing quoin-panel.mix (see the legacy handover). It also hosts the Scene Editor as a reserved entry, and serves its gallery, fork/promote/move and first-run verbs (below).

Files and installation

~/.config/cosmix/scenes/
  state.conf.mix
  <name>/scene.mix
  <name>/template.conf.mix
  <name>/behaviour.mix       # optional

scene.mix is an AMP scene document, never executable Mix. Metadata and state are strict data, parsed with data_parse. Only behaviour.mix runs. The document name must match its installation directory. Names contain lower-case ASCII letters, digits, - and _, at most 64 characters; leading - and _ are refused. The scene/metadata/behaviour files and installation directory cannot be symlinks. Shipped templates live at $COSMIX/share/scenes.

Without name, scenes.install uses the scene name the template authors: the directory name for every template except settings, which authors quoin-settings. An authored window.panel is kept when installing under the authored name and rewritten to scene-<name> on a rename. So {template:"settings"} installs quoin-settings on Quoin's declared page settings.appearance. While Quoin's built-in fallback holds that page, the loader's load takes it over (see Quoin). Disabling or removing the scene gives the page back to the fallback.

State has {schema_version:1,enabled:[],origins:{},recovery:{}}. Writes use write_atomic with full durability and mode 0600. Enablement is separate from template metadata. A directory discovered through the filesystem remains disabled. A loader lock prevents two loaders sharing one directory.

setup.mix --desktop installs bin/scenes.mix and its helpers; the checkout's share/scenes is the canonical template installation. Adding --system copies the loader/helpers and templates under /opt/cosmix too. No unit is enabled, no user scenes are seeded and Quoin configuration is not rewritten. The example src/_etc/systemd/cosmix-desk-scenes.service.in must be rendered by the session installer, with a desktop user, session environment and paths.

Environment overrides:

VariableMeaning
SCENES_DIRUser scene root; default $XDG_CONFIG_HOME/cosmix/scenes
SCENES_TEMPLATESShipped template root; otherwise $COSMIX/share/scenes, or discover the checkout from the script
SCENE_HOSTQuoin service; default shell
SCENES_LEGACY_SERVICELegacy panel citizen whose registration holds the four legacy pages; default quoin-panel
SCENES_FIRST_RUN0 turns off the first-run open of the Scene Editor; default on

The native Mix binary is resolved from the running interpreter. Behaviour children inherit the session and existing COMP_SERVICE, APPS_SERVICE, TRAY_SERVICE, NOTIFY_SERVICE and other overrides. They additionally receive SCENE_NAME, SCENE_HOST, SCENES_SERVICE, SCENE_GENERATION, and SCENE_DIR; their working directory is their scene directory. Each runs as scene-<name>. SCENE_DIR is the directory the behaviour runs from. For the Scene Editor's reserved entry that is either the shipped template or the user copy, so helpers kept beside a template load with require($SCENE_DIR .. "/lib.mix"). SCENES_LIB defaults to the shipped catalogue's lib directory, matching the shared behaviour helpers, and can be overridden explicitly. Generations are positive exact integers, allocated from an atomically persisted .generation counter before spawning. Disabling invalidates the active generation to zero. The document's citizen must match that routing name when it has a behaviour. Behaviours should use serve_name() for their actual identity. Without a window header, the loader follows Quoin's first window node for popup routing; an absent edge defaults to right. Managed popup page and edge values must be static; the loader refuses bindings on those routing fields. Host model patches also refuse a changed mount address without advancing the revision; unload and load to move a mount explicitly.

Public Bus contract

Verbs are mesh-open, with the same implementation for local and attested mesh requests. A refusal has nonzero RC and {error_code,message,context?}. Upstream RC, diagnostics and original payload remain under context; filesystem diagnostics also report file, attempted digest and last-good revision.

VerbArguments and result
scenes.list{} → {needs_setup,root,templates_root,state_ok,editor,scenes,diagnostic}; each scene reports installation, enablement, mounted state, revision, generation, behaviour status, ready/open/pending, origin, digests, diagnostics, last exit and journal location
scenes.install{template,name?,enable?} → installation receipt; stage, validate through shell.scene.validate, atomically rename, record origin; default disabled; refuse collisions
scenes.remove{name} → {name,recovery}; disable, stop, unload and move the installation to .recovery/
scenes.enable / scenes.disable{name} → acceptance/revision; persist desire and reconcile idempotently
scenes.reload{name} → acceptance/revision; retry files and failed behaviour, retaining runtime model
scenes.reset{name} → {name,recovery}; stage recorded shipped origin, validate, retain old copy, restore defaults; refuse unknown origin
scenes.ready{name,generation} → {name,generation,publish_model:true}; the behaviour then publishes its initial complete model
scenes.model{name,generation,value} → acceptance/revision; value is a complete map, submitted by the loader as shell.scene.patch {scene,path:"model",generation,value}
scenes.open / scenes.close / scenes.toggle{name} → {name,open,pending}; desired popup state, with applied completion reported through scenes.changed

Installation rewrites the scene name, behaviour routing citizen and an explicitly authored panel name to the installation name, so {template:"launcher",name:"preview-launcher"} mounts a second, distinct page.

Stage B legacy handover

scene-panel, scene-launcher, scene-calendar and scene-notes were mounted by quoin-panel.mix. Quoin's seats are owner-exclusive: whichever citizen holds a page keeps it, and the other one's load of that page is refused (SUBPANEL_COLLISION) until the holder unloads or disconnects. Neither owner can take a page from the other. While the SCENES_LEGACY_SERVICE registration (default quoin-panel) is present, a scene on one of those pages installs and enables normally but is not mounted: its diagnostic is SCENES_LEGACY_HELD with the holder name, and enable/reload reply rc 10 with it. That registration disappearing from the broker snapshot triggers one rescan, which mounts the held scenes and starts their behaviours. The handover is state, not that one edge: while the legacy citizen stays absent, an enabled, installed scene on a legacy page that is still unmounted (a load that timed out, or a SUBPANEL_COLLISION because Quoin had not yet applied the old citizen's disconnect) is retried on every later broker snapshot and shell.panel.changed notice. For a failure no later event would retry, the loader starts one local task_start backoff (1, 2 … 32 s, then it stops and leaves the diagnostic until nothing is owed); it never self-emits over the Bus.

The guard only stops the loader racing a live legacy citizen. It does not stop the reverse. If quoin-panel starts again while the loader holds the pages, the loader keeps them mounted (it never unloads for the legacy citizen), and the legacy citizen's own loads are refused by the seat rule, so it runs with no pages. While it stays registered the loader also will not reload or remount those pages, so their accepted content stays as it was. Operator rule: do not run both. Stop and disable the legacy unit before enabling the templates; starting it again is not a rollback until the loader's four scenes are disabled.

scenes.changed publishes a change-only inventory with a loader revision. Subscribers should take scenes.list after subscribing and repeat that snapshot after reconnection/gaps. Invalid edits leave accepted content visible. Unchanged accepted files and repeated model/exit/applied-state events are suppressed. Temporary and backup files are ignored.

Lifecycle and events

The loader registers filesystem watches and Bus subscriptions before reading state and directories. It takes the broker registration snapshot after subscribing, then mounts enabled documents as the loading citizen and starts behaviours. Quoin directs authored click commands to the document's citizen, with {scene,node,kind,value?,item?} in $event.args. Behaviours never load or unload their scenes.

fs_watch / on fs.changed batches trigger one reconciliation per affected scene; overflow:true triggers one rescan. The stable parent is watched too. Layout changes use full shell.scene.load, carrying the last accepted runtime model in the candidate. Model updates use the patch endpoint. Behaviour edits pass mix --check and mix lint --deny-warnings before replacement. Generation invalidation precedes stop; replacement waits for the retiring child's exit. The retiring record survives removal and reinstallation of the same name. Before spawning, the loader also reads the broker registry and waits for any old scene-<name> registration to disappear. Service snapshots resume that handover. A held scene-<name> registration reports SCENES_NAME_HELD with the holder name in scenes.list and scenes.changed; it waits for the name-release event without a timeout loop. Each name in a filesystem batch or rescan has its own error boundary, so one refused unload does not discard the rest of the batch.

Loader RELOAD is a genuine post-commit handover, not a refusal. The candidate branches on is_reload_candidate() and prepares passive state: it reads and validates the state file and every enabled scene's document, metadata and behaviour — local checks plus the host's read-only shell.scene.validate — and refuses the swap if any of them would not survive it. A refused candidate mounts nothing, starts nothing and writes nothing; the old evaluator continues serving with its children intact. On a successful swap the runtime reaps the old managed children and retires the old generation's legacy children BEFORE the commit hook runs, bumps lifecycle.generation, then queues on lifecycle.commit exactly once per committed generation: the loader reconciles durable desired state (a full rescan plus the service snapshot), mounting accepted content and starting each enabled behaviour exactly once, with fresh generation tags so a retired generation's proc.exited events cannot reach its successors. The hook is native-only — a wire-delivered lifecycle.commit is refused by the runtime — and a fault in it is a diagnostic the existing restart/reconnect reconciliation recovers. scenes.reload {name} still replaces an individual behaviour normally.

Managed spawn uses {die_with_parent:true,exit_event:true,tag:<JSON string>}. The string encodes {name,generation}; on proc.exited receives {pid,tag,exit_code,signal} in $event.args. A crash leaves the scene mounted. Four failure-triggered restart deadlines are 1, 2, 4 and 8 seconds; the fifth consecutive exit enters crash_loop. Running at least 60 seconds resets the count at the next exit, without a health timer. Explicit reload retries the failed scene. Disable during a deadline invalidates its generation. Deadlines use a separate in-process task_start task, never a Bus self-emit. The native exit handler performs synchronous bookkeeping only. The task's async phase waits without carrying writable state; its synchronous phase re-reads current state under the writer lock and performs the fenced restart. Concurrent exits retain both successors, and disable during the wait stays disabled. An elapsed deadline stays eligible when Quoin is absent and resumes on remount; reconnect reconciliation also checks the computed deadline directly.

The process adapter uses W1's registry-backed kill(pid,9) to stop the owned child through its retained pidfd. Generation checks precede stop and exit bookkeeping precedes the async deadline: the numeric PID is discarded as soon as its exit event is handled. Replacement waits for that event; the native reaper owns descendant cleanup. SIGKILL is deliberate so a stuck behaviour needs no stop poll or escalation timer. The installed pre-W1 interpreter cannot run this watcher/child path; deterministic tests fake only that boundary. The integration gate must confirm W1's PID retention and exit-delivery guarantees before enabling the unit.

on bus.connected and noded.props.changed repair state with full service snapshots and shell.scenes.list mount inventory. This also removes mounts disabled or removed while the broker was unavailable but still present in Quoin. An unload the host answers with "unknown scene" counts as unloaded: Quoin has already dropped it (an owner departure), so there is nothing left to remove. Any <host>.scene.changed notice with ops:["unloaded"] marks the scene not mounted; an enabled page is remounted at once (reason owner_departed), while a pre-empted editor waits for the next open. A notice older than the mount the loader holds (a lower revision) changes nothing. A returning shell is remounted from accepted source/model even when the current on-disk edit is broken. Behaviour service disappearance alone never unloads a scene. The owner is the verified loading citizen.

Popup selection, temporary pinning, mutual exclusion and saved-mode recovery advance from command replies followed by shell.panel.state reads. Already satisfied phases complete immediately. shell.panel.changed is a hint to read state, and every subscribed topic's gap causes a full resynchronisation. Recovery requires both the saved page and mode to be restored before its record is removed, including when both pages were already pinned. Hidden mode can remain visible because the pointer or a holder reveals it; matching page and mode completes restoration without cancelling that reveal. Quoin replies applied:true with the actual visible:true snapshot in this case. Refused popup commands settle with pending:false and a diagnostic in scenes.list; failed recovery records wait for an explicit open/close retry, not another hint. The legacy panel logs refusals and clears the pending attempt, retaining its pin recovery record for a later explicit retry or resynchronisation. Recovery is persisted before pinning. If another page takes the edge, recovery does not hide it. The legacy panel remains in use in Stage A but consumes the same applied-state stream; its network/audio conversion belongs to Stage C. Its minute clock and the calendar template use local async deadline tasks; bus.connected starts a clock only when it is not running. Gaps received during a resynchronisation coalesce into one further pass.

Render failures retain readable diagnostics and the last applied revision. A subsequent accepted revision or remount recreates missing renderer entities. The initial connection notice reuses the startup snapshot without remounting.

Deferred: .recovery retention remains manual until the editor offers a reviewable prune policy; automatic deletion could destroy the only saved edit. Deferred: the parent filesystem watch is retained for root replacement and shares the evaluator's inotify fd and worker. Deferred: per-expression Instant::now() budget checks remain until measured optimisation preserves the deadline guarantee for always-ready expressions.

Scene Editor

The Scene Editor is the shipped template editor, run by this loader. These verbs serve it, and any agent can use them directly. All are mesh-open.

VerbArguments and result
scenes.templates{} → {root,templates:[{template,name,title,description,edge,kind,behaviour,recommended,order,requires,installed_as,diagnostic?}]}. Every template directory except lib and those with hidden:true, sorted by order then template. An unreadable template is a row with a diagnostic, never a refusal
scenes.editor.open{safe?,view?,scene?,first_run?} → {name:"editor",source,safe,visible,pending,fallback,request_seq}
scenes.editor.close{unload?} → {name:"editor",visible:false,mounted}
scenes.fork{name,as?,enable?=true} → {name,installed:true,enabled,forked_from}
scenes.promote{from} → {name,recovery:[paths],removed}
scenes.move{name,edge} → {name,edge,revision}

The reserved editor entry

editor is created once at loader start, with reserved:true, and lives in the scene table like any other scene. Crash restarts, generation fencing, retiring and host-return remounts therefore apply to it unchanged. It is never enabled: its enabled always equals an in-memory summoned flag, which scenes.editor.open sets and scenes.editor.close {unload:true} clears. It is never in the persisted enabled list, never counts toward needs_setup, and is reported as scenes.list.editor, not as a row:

{user_copy, user_dirty, mounted, visible, source:"shipped"|"user", safe, fallback,
 behaviour, view, scene, first_run, request_seq, generation, kind, dir, diagnostic, problems}

enable, disable, fork, move, open, close and toggle of editor are refused SCENES_RESERVED. So is installing the editor template under another name or with enable:true. A plain scenes.install {template:"editor"} creates the user copy at $SCENES_DIR/editor. scenes.remove {name:"editor"} and scenes.reset {name:"editor"} act on that user copy only, never on the shipped directory, and never delete the entry. If the user copy was mounted, remove switches to the shipped copy first, and reset reloads the restored copy.

For any other scene, candidate refuses the page id scene-editor and window.kind:"dialog" with SCENES_RESERVED. The one dialog seat belongs to the editor in v1.

Open. A non-safe open checks the user copy with candidate, shell.scene.validate and a behaviour check with mix lint --deny-warnings. If that fails, or the copy then fails to mount, the shipped copy is used and fallback {error_code,message,file} names why. The shipped copy is always checked in report mode: mix --check is a hard gate, but its lint findings become editor.problems, never a refusal. A safe open while the shipped copy is visible hides it (the chord's toggle). The loader loads a dialog-kind editor with preempt_dialog:true, so a squatter cannot hold the seat, and shows it with shell.dialog.show. visible settles from shell.panel.changed (dialog.visible), never from the command reply. A Quoin-side hide (× or Escape) records visible:false; during needs_setup it also sets dismissed. A pre-emption notice, or a snapshot whose dialog no longer names the editor, also records visible:false.

SCENES_EDITOR_UNAVAILABLE is the one refusal with no fallback. It covers a missing or invalid shipped editor, a scene host that is not registered (context.shipped.error_code:"SCENES_HOST_ABSENT"), and a host without dialogs (SCENES_HOST_KIND). The dialog probe is shell.dialog.hide {scene:"editor"}: NOT_FOUND, NOT_DIALOG or success mean the host has dialogs, while UNIMPLEMENTED or an unknown verb mean it does not. shell.scene.describe alone is not enough, because from cosmix-scene 0.6 it lists dialog before the host can mount one. An editor whose header is kind:"edge" (the fallback when the host has no dialogs) takes the popup path instead: select, pin, then restore the saved mode on close.

First run. On the transition of the scene host to live (including the loader's own first snapshot), after the remount, the loader opens the editor on gallery with first_run:true. It does so only if needs_setup, the state file read cleanly, the editor was not dismissed in this process, and SCENES_FIRST_RUN is not 0.

Events under $SCENES_DIR/editor/ are not reconciled as a scene. They set user_dirty, and reload the entry only while the user copy is the mounted source; a broken reload keeps the last good tree.

Problems and digests

Each scenes.list row also carries:

  • title, edge, page, kind;
  • files {scene,behaviour,metadata};
  • digests: the sha256 of each file's current bytes, whatever was accepted;
  • forked_from;
  • problems:[{file,digest,line,col?,severity,code,message}].

problems is derived, never persisted:

  • A loader diagnostic maps to its file at line 1.
  • A host diagnostic (context.upstream.diagnostics) keeps its line.
  • A behaviour check carries mix lint --json lines and columns.

A row's digest is the sha256 of the bytes its diagnostic describes. For a refused load that is the attempted bytes, not whatever the file holds now, so ced shows the row as stale once the file has moved on.

A refusal of the file itself (candidate, behaviour check, load or remount) stands until a later reconcile of that scene succeeds: a good save, a scenes.reload, or a host-return remount. A model publish, a behaviour's ready and a crash restart never clear it, so a broken save stays visible while the last good tree keeps running. Runtime faults (a behaviour exit, a refused model patch) are reported beside it, and the row's diagnostic is the file's refusal when there is one.

scenes.changed carries the same inventory. Names of scenes with a behaviour must fit the Bus name rule: at most 25 characters and no _, so that scene-<name> is a legal service name. Install, fork and reconcile refuse others with SCENES_BUS_NAME.

Fork, promote and move

scenes.fork {name} stages a copy of the installation, not the template. It rewrites the name, citizen and page to the new name (<name>-sandbox, else <name>-sb), and records forks[as] = {from, panel?}. The fork inherits the origin, so reset works on it, and it is enabled unless enable:false. scenes.promote {from} stages the fork back under the original name and page. It validates that staged copy, then swaps it in through persisted phase records: promoting {from,to,stage,backup,phase:"staged"|"swapped"|"placed"}. The old original goes to .recovery/<to>-<uuid>, and the fork is removed to .recovery/ too. reset uses the same records under resetting. A fork whose original has been removed cannot be promoted (SCENES_NOT_FOUND); a promote or reset while an unfinished record of its kind is on disk is refused SCENES_RECOVERY, naming that record, and nothing is staged or stopped. Once the swap is on disk, a failure to retire the fork is a reply (rc 10, context.phase:"finish", with context.sandbox naming where the fork went if it had already been moved to .recovery/), not an undo: the placed record is finished by the next scenes.reload of either scene or the next loader start.

Crash recovery at start is decided by what is on disk, (to, stage, backup), not by the phase alone:

On diskAction
to and stage present, no backupnot swapped yet: delete the stage
no to, stage and backup presentcrashed between renames: place the stage, then finish
only backup presentstage lost: restore the backup
only stage present (there was no to to back up)phase staged: delete the stage; later phases: place it, then finish
to present, no stagephase staged: never swapped, clear the record; later phases: placed, finish
anything elseleave it; SCENES_RECOVERY diagnostic with the triple

Each record recovers in its own error boundary, so one bad record never stops the rescan. It is retried on scenes.reload {name} or the next loader start. stage and backup are stored relative to the scene root. .stage-* directories never count as scenes.

scenes.move {name,edge} rewrites only the window header line of the user's scene.mix. A move between orientations drops w/h. The loader then reconciles: when a mounted scene's page or edge changes, it validates the candidate first, then unloads, then loads. If the new load is refused, it re-loads the last accepted source at the old address. If that rollback fails too, the scene is mounted:false with SCENES_REMOUNT_FAILED (context:{new,rollback}), retried by scenes.reload, the next file event or the next host return. Either way a refused scenes.move puts the original header back on disk, so later rescans do not retry the move. The loader never writes Quoin's conf.mix; the editor follows a move with one shell.panel.order naming both edges. A dialog scene, or one without a window header, is refused SCENES_MOUNT.

Unreadable state

state_ok is the outcome of the last read_state and nothing else. While it is false, every persist refuses SCENES_STATE_UNREADABLE without writing, so an unreadable state file is never overwritten and its origins are not lost. The same refusal applies to every mutating verb, before any side effect, and to popup opens that would persist a recovery record. First run is suppressed. The dialog editor needs no persist, so safe mode still opens. State schema_version stays 1; forks, promoting and resetting are optional maps.

Gates and installer follow-up

Run mix src/desktop/scripts/tests/scenes-test.mix for deterministic loader transitions with real temporary files. scenes-mesh-test.mix checks list and all mutation refusals over local and attested mesh routes, using the two target environment variables documented in that test. Rust regression tests cover aggregate model bounds, transactional refusals, retained bindings/ports, existing list models and bound row refresh, authored-source round trips, behaviour disconnect and applied panel notices. The build cluster runs the Rust gates and native W1/Quoin integration tests.

The private session installer must install the loader, helpers and templates; render the example unit in the desktop session; and supply the matching native Mix binary and Quoin. For Stage B it explicitly seeds the chosen set (never a fresh install's default), retains popup recovery and rollback material, and stops the old citizen; the loader's handover rule above then mounts the existing page IDs. Readiness checks must consume service/readiness/applied-state events. No private installer is changed by this public workstream.

Limits and remaining operational assumptions: at most 128 installed scene records, 256 KiB per scene/behaviour file and runtime model, and 64 KiB state or metadata. Recovery copies are retained for the operator to manage. Recovery mutations reject symlinks (including dangling links), non-directory recovery paths and destinations outside the canonical scene root. The loader retains a native directory handle at initialisation and uses dir_rename for recovery moves. The kernel rejects symlink sources, parents moved outside the retained root and existing destinations at rename time. Linux openat2 and Landlock ABI 2 with the disconnected-directory erratum 3 fix are required; unsupported kernels refuse the operation. A root replaced before a move is refused. If its original pathname changes after that check, the retained root inode remains the containment boundary. Other staging and state writes still assume trusted desktop-user edits; this API secures the recovery rename, not every filesystem operation in the loader. Reset uses two renames with rollback on an ordinary error; power loss between those renames leaves the preserved copy under .recovery. Multiple processes directly editing state do not share the loader's advisory lock. A transport timeout can leave the upstream mutation applied before a later event repairs local state. The private .generation counter must not be removed or rolled back while old behaviour messages can still be delivered; ordinary install/reset never touches it. The advisory loader lock assumes the root directory itself remains in place; replacing the root can detach that lock inode even though native watches recover. Stop the loader before replacing its whole root. Runtime model writes for managed scenes must use scenes.model. The loader mounts with a JSON shell.scene.load {source,model_generation} envelope and updates the fence before each spawn; zero disables model writes. The host requires both the verified loading citizen and matching positive generation for model and model.* patches. Direct behaviour/editor writes are refused without changing the model or revision, and a raw load cannot strip a managed fence. Local and mesh editors keep access through the loader's generation-aware verbs, so its retained model survives remounts. Unmanaged scenes keep direct model patches. The local/mesh gate assumes its two configured targets reach the same citizen over distinct routes.