Skip to content

State and the tree

The Riverpod providers, the seams the agent layer attaches to, and the tree’s model, builder and view.

| Provider | Type | Responsibility | | projectProvider | AsyncNotifier<FilmProject> | Loads the sample at start — as a writable session copy on every platform, whatever development mode says, since the mode’s switch is in the preferences every build on a machine shares — with the library from libraryProvider (which it watches: choosing another library folder re-reads the open project). openDirectory(path), openSample replace the page (loading spinner) and, on success, put a folder at the top of the recent list — never the sample, which has no root; each ends a closed state. openSample extracts the sample afresh (§6) only once the film being left has settled, so that an open refused makes no folder; a source that cannot be made opens nothing, the film staying and not left. close writes every open draft (OpenDrafts.settleOpen), prepares the film for leaving — its documents sent within what remains of kSettleBeforeLeaving (SyncAppBinding.prepareLeaving, §6.14) — and sets projectClosedProvider, logged as project.closed; opening another source, and a project created or imported once read back whole, prepare the film being left the same way, after its drafts are settled and before the source changes, a superseded open undoing it; a leaving whose drafts could not be written refuses the open as OpenRefusedException (an import as drafts_unsaved), the film staying. Every write of a viewer’s commentary file goes through one queue per file — the file’s stem cm_<type>_<tag>_<viewer>, in the film it was asked in: setPick, abandonPick, keepPick, the follow of official setOfficial writes — in the film the star was pressed in, fixed before the pointer’s write — the pick a fork, createEntity, addChild or selectProject writes, and visit. Each runs after the gesture before it in that queue has published its index (§7.5), against that index, so a visit and a pick of one file, in either order, both land; a write whose film is no longer open — closed, replaced, or with another source opening — is refused as ProjectNotOpenException and writes nothing, and while another source opens nothing re-reads the film being left (a rebuild of the same film, for a new library folder, is no open: its writes land as ever). Opening another source — a folder, the sample, a project just made or imported, a session copied to a folder — lets what the film being left still has to write land first, while it is still the one open: the controller’s own writes still queued or running (a pick clicked behind another, a star’s pointer, a visit, a change below a form, and one asked as the drafts settle — a fork waiting for them), then every open draft, as a change of username does (OpenDrafts.settleUntilQuiet), pass after pass until one finds both quiet — so that a pick clicked while a draft was being written lands too — at most OpenDrafts.maxSettlePasses passes, all within the 10 s closing the window allows (kSettleBeforeLeaving); of two opens asked meanwhile, the one asked last opens and the other is not begun: a draft that cannot be written — a changed file, a failed write — typing that outruns the passes, or the time running out refuses the open (OpenRefusedException), the film staying with its form’s own refusal and the page saying the other was not opened (openRefusedByDrafts, with the refusal’s sentence where it has one); only then does the open begin, and the film being left count as closed. ProjectController.isCurrent(film) is whether a film is still the one open: not closed, not replaced, and not being left — from the moment another source is asked. The file menu’s Edit JSON… and Plug-ins take their film as they are asked and ask it once the draft they write first is written (WriteActions.stillOpen), since the project menu stays live during that wait: a dialog whose film was left meanwhile is not opened. A page’s write answers on the page it was started from while that page is mounted, and says nothing once it is gone. createOutfit runs in a queue of its own, the character’s and the viewer’s owner’s — the allocation’s namespace, since the outfit’s tag, and so its commentary file, is known only once the writer has allocated it, and an owner and their workspace allocate in one namespace; there a stale refusal is the allocated id taken meanwhile from outside, and is the answer: nothing is tried again, the file a stale refusal named is read into the index (§7.5), and the person’s next try allocates the next id; with a draftLabel (the assistant’s, create-entity) the outfit is a draft, labelled and not picked (ProjectWriter.createOutfit(…, draftLabel:)), and a gate runs in the queue after the wait and before each write, what it throws refusing the creation unwritten; the browser passes neither. A stale refusal inside the queue is then a change made outside the app, refused as every changed file is (§6.1), the file read into the index for the next try (§7.5). visit(entity) records the viewer’s last look (Project Specification §13.4) and is bookkeeping: nothing is written without a handle or on a project that cannot be written, a refusal is logged as visit.failed and never thrown, and a visit queued behind one that recorded the same instant writes and publishes nothing. deleteTake(reference) asks the writer to delete a take’s file (§6.1a), one gesture; its refusals — another author’s take, a take another document still names — are said through writeProblemText. createProject(...) writes the folder (or a session-only project where there is no disk) and opens it, answering a CreatedProject: the new pj stem and the stem of the scene the script starts with, which the caller opens. addChild, removeChild and moveChild change the script’s lists (§9.2): an add writes the entity first and the parent second, a move the destination first and the origin second, and both answer — AddedChild.notListed, MovedChild.notRemoved — what kept the second write from landing, rather than throwing it, since the first has landed and the caller has to say so, and a move also answers MovedChild.alreadyListed where the destination named the folder or scene already, which the caller says too; a change of a list that would change nothing is UnchangedChildrenException, which a move takes as done — for the destination (it names the entry already: the move is the removal alone), for the origin (it no longer lists it: the move is the add alone) and within one list (it is there already) — and a list that is not what it was when the person asked, its file gone from the index or the list read back not the one intended, is ChildrenChangedMeanwhileException; both have their sentence in WriteProblemText, so that no developer’s English reaches a person. createEntity(type:, tag:, epoch:, name:, kind:) writes a new character or location as the viewer’s v1 (§6.1a), one gesture, returning its stem. refresh reads the folder whole — a publication of kind reread (§7.5), queued behind any publication under way — without entering a loading state, and reads nothing while the film is closed; refresh(library: true), a plug-in install’s door, first drops the library held across opens, so that read and the next open list and read the library afresh; so the current project stays on screen; it answers whether an index was published, and a read that would land after another source opened replaces neither the state nor the base index. selectProject(stem) answers the chooser. copySessionTo(parentPath) keeps a session copy: every listed file, including media and thumbnails, is copied into a new folder named after the project (creator.copyProjectTo, refusing a folder that holds files), and the project is opened there. saveEntity, fork, setOfficial, setPick call the ProjectWriter with the viewer from Settings, each one gesture (§7.5); saveEntity answers the file as the writer wrote it, or null where its film is no longer open, so a form’s saver checks its next write against the text it wrote; setOfficial removes the director’s own pick at that epoch (the star alone then marks what they use), setPick on the version that is already official removes the pick instead of pinning it, and abandonPick / keepPick are the two answers to a pick an official version overtook — the second re-dating it; abandonPick answers false, writing nothing, when the viewer had no pick of their own there. Every load ends with FilmProject.forViewer on the index the loader built (kept as the base), and build listens to the viewer handle to re-apply it to that base without re-reading the folder. selectProject (the chooser’s answer) records the choice as the viewer’s pick when the project can be written and a handle is set, so it holds on the next open; otherwise it lives for the session. Every open, write, success and failure is logged. | | insertionServiceProvider | Provider<InsertionService> | The application-owned service installed by the entrypoint, overridable in a test; submission and actions survive the page that requested them. | | jobViewsProvider(placeKey) | StreamProvider.autoDispose.family<List<JobView>, String> | Subscribes before seeding its current place snapshot, so a returning card immediately sees existing work, failures and offered actions. | | projectClosedProvider | Notifier<bool> | Close project: while true the tree pane shows its header and menu only and the detail pane Start a new project or open an existing one. The index stays loaded underneath and nothing reads it; opening or creating a project sets it false. | | projectSourceKeyProvider | Provider<String?> | Names which film is open, unchanged by a re-read: the format’s projectKeyOf (§5.15) and no rule of its own, the same token a run of a plug-in is bound to its film by (§6.11). The browser resets its navigation on this, not on every new index, so a page survives the re-read that follows a save. | | scriptViewProvider | Notifier<ScriptViewState> | How the open film’s script is read (§9.3): the Script tab’s mode (Page, Scene grid or Timeline), the timeline’s colouring (the grid’s stripe or a hue per location) and the filters of a folder’s and the whole film’s reading — the where-and-when pairs, a character, a location, a prop or resource, the search — with failure, the first filter a scene fails and what it compared. It watches projectSourceKeyProvider, so a re-read keeps it and another film resets it; it is never written to a file or a preference. Each setter logs script.view, the colouring’s with its time; the search’s own setter logs nothing, the tab logging the settled query. Clear empties the filters and keeps the mode and the colouring. | | settingsProvider | Notifier<Settings> | themeMode, viewerHandle (the username, default where none is chosen — §9.4; changed only through setViewerHandle, which writes the open drafts first — pass after pass, OpenDrafts.settleUntilQuiet — and answers a ViewerChange; viewerStored() waits for the username’s last write to the preferences — the fallback write of default included — and answers whether it landed, which the agent’s quit awaits; a caller about to write as it reads it once viewerSettled has answered), unsaved (the preferences whose last write failed, kept for the session; retryWrite writes one again), newProjectParent (the folder New project last made a project in, §9.2; rememberNewProjectParent after a project was made and opened there, through the same failure-keeping write; a stored value that is not a text is none, logged as settings.unreadable by the preference’s name), viewerSettled() (completes once no change of the username waits on the open drafts: a caller about to write as the username reads it after this), sidebarWidth, locale (null = platform), verboseLogs (applied to AppLog.instance on load and on change), recentProjects (most recent first, at most twelve, each a {path, name} JSON string; unreadable entries are dropped), libraryPath (null = the app’s own folder), accountSignedIn (the §6.4 hint, setAccountSignedIn(bool), written by the account’s state and logged, and read by the rail’s profile), warnInsteadOfRefuse (development mode, §9.5: setWarnInsteadOfRefuse(bool), logged at its start with the value it replaced and at its end with saved or failed, the code, the reason and the elapsed time, and answering whether the preferences kept it — the switch stays for the session either way; its default before anyone chooses is developmentModeDefaultProvider, true wherever the build has the mode — developmentModeAvailableProvider, which is kDevelopmentModeAvailable, false in a release build or one built with --dart-define=FILMOPEN_ENFORCE_GUARDS=true — and the widget tests override it to false; a build without the mode reads no stored switch, and Settings shows no control for it; the load logs settings.developmentMode with the availability, the stored switch, the default and the value in effect; Settings.guards is the policy every write applies). Persisted through sharedPreferencesProvider, which main overrides with a real store and tests leave null. | | libraryProvider | FutureProvider<ProjectSource?> | The shared library: the folder chosen in Settings; else the app’s library folder, seeded from the bundled copy on first run; else (web) the bundled copy read in place. | | recordingProvider | Notifier<bool> (RecordingFlag) | Whether the issue recorder is running (§6.8), set by main from the recorder’s own notifier and watched by what must not be in a recording’s pictures: a key box’s eye (§9.4). | | incomingLinksProvider | Provider<IncomingLinks> | The platform’s door for the links the operating system hands the app (§6.6): the sign-in’s return, on Android an intent. main installs the device’s; a test overrides this with a MemoryIncomingLinks it delivers to. | | launchArgumentsProvider | Provider<List<String>> | The process’s command line, overridden from main; empty in tests. On Windows a sign-in callback arrives this way when the app was not running (§6.6). | | preferencesLocationProvider | FutureProvider<String?> | The preferences folder, for Settings → About; null on the web. | | projectsInAppFolderProvider | Provider<bool> | Whether projects live in the app’s own folder — a phone — rather than in a folder the person names (§6.4). The platform’s answer, overridable in a test. | | folderDialogsAvailableProvider | Provider<bool> | Whether the operating system offers a folder dialog whose answer is a path: Open project…, Copy to a folder…, the library folder and the project folder’s Browse are drawn only where it does. | | projectsDirectoryProvider | FutureProvider<String?> | Where a new project is made where projects live in the app’s folder: projectsDirectory, created; null where the person names a folder, and on the web. | | accountOpenerProvider | NotifierProvider<AccountOpener, int> | The one way the app asks a person to sign in to filmopen.ai (§9.4, §6.6): open() lands on Settings and counts a request, on each of which the rail’s profile opens the account’s popup — one at a time. The support flow’s Sign in calls it through the dashboard. | | documentsDirectoryProvider | FutureProvider<String?> | The person’s documents folder as the operating system names it (documentsDirectory, through path_provider), where New project proposes a project when it knows no better place (§9.2); null on the web and on a phone, whose projects folder above means something else; an error where the system will not say, logged as project.documentsUnavailable with its type and message. No retry. | | newProjectParentProvider | FutureProvider.autoDispose<String?> | The parent folder New project proposes (§9.2), read afresh at each opening of the dialog: Settings.newProjectParent, else, where none was saved, parentFolderOf the last project opened, else documentsDirectoryProvider — either folder only where directoryExists, a saved one since deleted giving way to the documents folder; the settings read once, so a project opened meanwhile does not move the box. Null on the web and a phone. | | folderSourceProvider | Provider<ProjectSource Function(String)> | How a folder on disk is read as a project: directoryProjectSource, which Open project… and New project use; a test hands in a folder that writes and will not list. | | localizationsProvider | Provider<AppLocalizations> | The strings in effect for code without a BuildContext: the chosen locale, else the platform locale, resolved to a supported one by language code. Widgets use context.l10n; both resolve the same way. | | scriptOutlineProvider | Provider<ScriptOutline?> | The one walk of the script (scriptOutline, §5.12) for the project and the viewer, null while no project is loaded. The tree builder is handed it, and the Script page, a folder’s contents and what an add or a removal says read it, so they agree by reading the same rows and a long script is walked once for a change, not once for each of them. | | treeModelProvider | Provider<TreeModel> | Rebuilds the tree from the project, the viewer, the language and scriptOutlineProvider’s walk. | | workflowDoneProvider | Provider<WorkflowDone?> | The answers to the done checks of the workflow a scriptwriting plug-in declares: WorkflowDone.check(done, focus) gives StepDone.done, notDone or noCheck for a step’s done — the workflow file’s JSON as parsed — on the project file, a folder or a scene on screen. Counted once per project snapshot and viewer by one walk of scriptOutlineProvider‘s rows as the Script tab walks them: a cycle, a second listing and an unresolved row skipped, a deleted row not counted though what a deleted folder holds counts for the folders above it. An entities check counts the folders or scenes beneath the focus — never the folder itself, and for the project only what its children reach — under the type and, where given, the kind and tag, against min (1 when absent, 0 allowed); characters, locations and documents count project-wide as the viewer resolves them, the shared library’s included, a deleted one not; a scene answers for itself; a folder version the walk did not draw is walked once on first ask. A field check is done when the value at its dotted path is text with more than spaces, a non-empty list or map, a number or a boolean. Anything else is noCheck. Logged workflow.done.counting {project, viewer, rows}, one workflow.done.skipped {row, parent, reason} per row left out, workflow.done.counted with the folders, scenes, skips, the project-wide counts and every folder’s counts and the elapsed time, workflow.done.folder.counting and workflow.done.folder for a version walked on first ask, and a throw in either walk workflow.done.failed with its stage, row and error before it is thrown on; a single check, read per draw, is not logged. | | workflowScopeProvider | Provider.family<WorkflowScope?, String> | What a Workflow focus gathers for the viewer, by the focus’s full stem: WorkflowScope.of(project, focus, rows, viewer) over the script’s rows at the project file, the folder’s own outline at a folder, and the scene alone at a scene, walked as the Workflow tab walks them — a cycle, a second listing, an unresolved and a deleted row left out, what a deleted folder holds walked in its place. Its scenes are the walk’s lines (a folder’s heading, or a scene with its block ids in script order); its characters, locations and props begin with one ScopedRow per tag and occurrence epoch, in first-appearance order (in a scene, its speakers, then its cast) — a character’s epoch its cast entry’s, else the scene’s (a speaker not in the cast at the scene’s), a location’s location_epoch, else the scene’s, a prop’s own {prop, epoch}, else the scene’s, and no epoch at all the project’s first — each resolved as the viewer’s own page resolves it (FilmProject.resolve with the viewer and without referencing), fellBack where the scene’s epoch fell back to the first epoch’s version (an epoch the cast entry, location_epoch or the prop names itself never falls back: with no version it is skipped noEpoch), and owner only on the first row resolving to a stem, a later one a use of it. Under each character, every outfit of that character at the row’s occurrence epoch — though the character fell back to another epoch’s version —, as the outfit list enumerates them, with the same marks. skipped holds each row left out and each reference that does not resolve — every occurrence resolved before the rows are merged, so an explicit epoch’s refusal is kept beside an implicit fallback of the same tag — with the format’s problem kind, never guessed. A version in the recycle bin (isEntityDeleted) claims no row, and is skipped deleted. After the walk the inventory adds to characters and locations (props stay the scenes’) every group, in group order, at every epoch with a retained version where it has no row yet, resolved explicitly: a group in the recycle bin for the viewer (isGroupDeleted) is skipped deleted; a library group at a pair no scene uses is skipped library; a row that would own no card and bring no outfits is skipped alreadyShown. ScopedRow.inScene is false on an added row no scene uses, from sceneUsage(project, viewer): the whole project’s set of ch_<tag>@<epoch> and lo_<tag>@<epoch> pairs, for every scene version in the index, each speaker, cast entry and location resolved with the walk’s rule but for the recycle bin, which it does not read, each pair naming the version’s own epoch, so a use that fell back counts at the epoch it fell back to and an explicit epoch with no version counts nowhere. Not gathered: @ mentions in dialogue, a scene’s unassigned outfits. Null while no project is loaded and for a stem the project does not hold; gathered once per snapshot, focus and viewer. Logged workflow.scope.computing {focus, viewer, rows}, one workflow.scope.skipped {row, parent, reason, epoch} per row left out or reference unresolved, workflow.scope.fellBack {type, tag, epoch, stem} per fallback, one workflow.scope.usage.unresolved {scene, row, epoch, reason} per use that resolves to nothing, workflow.scope.computed with every row as tag@epoch→stem, the outfits, scenes, blocks, skips, used (the whole set), added: [{row, inScene, reason}] (notInAnyScene or usedByAScene) and the elapsed time, and a throw workflow.scope.failed {stage, row, error, ms} before it is thrown on. | | workflowPreferencesProvider | NotifierProvider<WorkflowPreferencesController, WorkflowPreferences> | The workflow’s two machine preferences, (plugin, ceiling): the chosen scriptwriting plug-in’s tag under plugins.prefer.script-writer — the plug-in host’s own key for a preferred plug-in of a kind, read and written through the same store — and the ceiling in USD a step’s run stops at under scriptWriter.ceiling, a number; null for none. Read once at build — a stored value of another type, or a ceiling not finite or not above 0, read as none and logged workflow.preference.unreadable {key, value} —, logged workflow.preferences.read {plugin, ceiling}. choosePlugin(tag) (null or empty removes the key) and setCeiling(usd) (null removes it; not finite or not above 0 refused, the state unchanged, workflow.ceiling.refused {value, was, reason, ms}) make the value the state at once, then write it: logged workflow.plugin.choosing / workflow.ceiling.setting {was, now} before, and workflow.plugin.chosen / workflow.ceiling.set {was, now, written, ms} after, with the failure’s code and cause — a failed write, or no store (no-store), keeps the value for the session, with no retry. A tag no longer installed stays chosen; the ceiling has no upper bound here, the host enforcing it. | | browserProvider | Notifier<BrowserState> | selection (what the detail pane shows), nodeId (highlighted row), toggled (rows flipped from their default expansion). navigate(selection, {nodeId, compare}) is the only way to change what is shown; it resolves a parent to the exact version its page would display and highlights that author’s surviving row, or the parent where only one handle has versions. A supplied parent node id does not prevent that move, and the resulting row’s ancestors are revealed. An exact requested stem and comparison pair are preserved; a representative row never replaces the displayed stem. Whole library entities keep their epoch overview. compare (ComparePair: the left and right stems) asks the page for an exact comparison, held until the next navigation, which drops it; openCompare(left:, right:) is its one door, taken after a write the app made — a change described and forked (§9.3) — and it opens the right version’s page on Compare only where both are versions of one entity at one epoch in the film open and the left is the viewer’s own and not the right, else nothing moves and it answers false. toggle(node), setAllExpanded(bool). Resets to the selected project version when another source is opened (projectSourceKeyProvider), not on a re-read. An undecided project keeps its chooser, and a missing project its diagnostics landing. A chooser publication lands on the selected project, including a read-only session choice. The overview/null project landing stays unpinned and follows the selected project when the viewer changes. A project entity request keeps the resolved selected stem, including a read-only chooser choice; an explicitly requested stem stays pinned. Each gesture, source reset and chooser landing logs browser.navigate.start and browser.navigate, with its door, requested selection and node, resolved stem, author and epoch, highlighted node, viewer, decision reason, whether a supplied node was redirected, code and elapsed time; noProject means no loaded project yet, while noProjectVersion means a loaded folder without its project version; an unexpected failure logs its stage and cause and is rethrown. | | treeFilterProvider | Notifier<String> | The sidebar filter text. | | navIndexProvider | Notifier<int> | Which rail page is shown. | | settingsTabProvider | Notifier<int> | Which of Settings’ tabs shows (SettingsTab.general, SettingsTab.plugins). Settings’ tab bar follows it, so a link elsewhere can open Settings on the tab it needs (§9.4). | | catalogueProvider | FutureProvider<Catalogue> | The bundled model catalogue (Project Specification §14.6–§14.9), read once per run through the format’s one reader (Catalogue.read over filmopen_platform’s readBundledText): the index’s listings, every platform file pending or not, and every entry with the JSON its file holds. Provider keys, dictation and the plug-in host take what each needs from it. The read is logged catalogue.read.start, then a file that could not be read as catalogue.unreadable, an issue inside one as catalogue.issue, each with its code, field and the error’s message, and catalogue.read with its outcome (read, partial, or empty where the index gave nothing), the listings, the counts and the elapsed time. | | providerKeysProvider (filmopen_keys) | Notifier<Map<String, KeyState>> | Each platform’s key state, read from the preferences unless the credential store is ephemeral; Save and Remove, one action per platform at a time (§6.7). | | providerCatalogueProvider (filmopen_keys) | FutureProvider<ProviderCatalogue> | Out of catalogueProvider: the platforms that are not pending and take a key, in the index’s order, and how many are pending. A platform whose card would repeat another control’s identifiers is left out and logged. |

Expansion state stores toggles away from the default rather than the expanded set, so a freshly loaded tree keeps sensible defaults (Script expanded, Characters expanded, scenes collapsed) without a reset step.

mediaStoreProvider (filmopen_state/media_provider.dart) is a Provider<MediaStore?>: LocalMediaStore.of(project) for the open project’s own media/ folder, null while no project is open. It is derived from projectProvider, so every load, re-read and viewer change answers that project’s store. Insertion and the plug-in host consume the store directly.

Adding to and removing from the script (ProjectController.addChild, removeChild). addChild(type:, tag:, name:, kind:, parentStem:) writes the viewer’s first version of a new folder or scene and lists it at the end of the children of the file parentStem — the project file the script is read from, or a version of a folder — the entity first, the parent second, as a fork writes its file before its pick. Before anything is written: a taken short name is refused (EntityExistsException, the dialog’s own sentence); every open draft is settled, and one that cannot be written refuses the add; the parent is looked up by its stem in the index as it then is — never the entity a caller held, whose text a settle may have replaced — and must be one whose list the viewer may change (PageAccess.childrenLock, below), with a children that is a list or absent and a change that changes something; and the parent’s save is tried dry (ProjectWriter.checkSave) against what the write will start from — the file on disk, or, where a form still has something to write on that file, the document that form would write (OpenDraft.pending, §7.1), so that a keystroke that landed after the settle refuses the add before the new file is made rather than after it. One viewer, read once, writes both files. The parent’s change then goes through the draft a form has open on that file, when one has (§7.1), else as saveEntity — an add’s of the parent as the index then holds it, once its turn in the parent’s queue comes; a move’s of the entity that was checked, never one looked up again: an origin looked up afresh would take a change somebody made since for the write’s own starting point instead of refusing it as stale (§13: a changed file is refused, never overwritten) — the entry is then listed twice, and the answer says so; either way the app’s own landed write to that file, not yet published, is what it starts from (§7.5); and success is read from the file as the writer wrote it — the entry is in the list — the patch’s proof on the writer’s hand-back (§7.5), never a re-read. The answer is an AddedChild: the new stem and, where the parent could not be written after it, what refused that (notListed) — an answer and not an exception, because the new file exists, under Not in the script, written as the handle the dialog set, which an exception’s way out would put back. removeChild(parentStem:, entry:, occurrence:) takes the entry out as it is written, the occurrence-th of equal ones, under the same checks and the same routing, deletes nothing, and throws what refused the write. One change of the lists at a time: every add, removal and move goes through the controller’s children queue, one after another, the film, the viewer and an add’s author bound when it is asked, so each decides from the lists the one before it left — two folders moved into each other at once are one move and one refusal. A move’s place is found at its turn: moveChild(…, beside:) puts the entry before or after the occurrence-th listing of a row’s entry (ChildBeside) and by: moves it that many places from where it is, both read from the list as the view holds it then (§7.5), never from an index the tree drew; a row beside names that the list no longer holds refuses the move (ChildrenChangedMeanwhileException), nothing written. Log: entity.created as before; each change’s folder.childAdd.start, folder.childRemove.start or folder.childMove.start with what it was asked, the bound viewer and film, and whether it queued, ….turn with its wait where it did, ….refused with the refusal’s type, message and time, and ….notPublished, an error, where the writes landed and the publication failed; for a move folder.childMove.start with the child, both parents, the occurrence and the index, beside or by, folder.childMove.decided with what each list will be and where the entry lands in the destination (at) (add or alreadyListed; remove, notListed or unfiled), folder.childMoved with its outcome and time, and folder.childMoveHalfDone, an error, where the destination landed, or already listed the entry (alreadyListed, nothing then landed), and the origin’s write did not — the entry listed twice — naming both lists, the destination’s list as it landed (landed), the origin not written (notWritten), the refusal’s type and message and the time, the move answering MovedChild.notRemoved with nothing undone; folder.childAdded, folder.childAddFailed, folder.childRemoved, each with the parent’s and the child’s stems and its time.

The script’s operations — the add, the removal and the two moves — are ProjectController’s through an extension in a part of the provider’s own library (children_controller.dart), which is what lets them read the controller’s private doors (_require, _provider): Dart has no partial classes, and a mixin in a library of its own would have had to make those public. The extension’s name is public, since the barrel exports the provider, so an import that narrows the barrel with show ProjectController must name ChildrenController too or these operations are not there.

Moving many rows at once (ChildrenController.moveChildren). moveChildren(moved:, toStem:, beside:, index:) moves the rows a person marked — each its origin (fromStem, null for Not in the script) and its entry, in the tree’s order, one order for all of them — into the children of toStem, at index or beside a row, found at the move’s turn as moveChild finds it, but counted on the destination’s list without the rows being moved, so a beside row that is itself one of them is one the list no longer holds and refuses the move (ChildrenChangedMeanwhileException). Any number of rows, one included, takes this one path; moveChild is the single row’s own. It is one change of the children queue. Before anything is written, from the film as decisions read it (§7.5): an entry its origin no longer lists is refused (ChildrenChangedMeanwhileException naming that origin); an entry its list holds more than once — as written, or as another spelling of the same folder or scene, as the add counts it — or a beside row the destination holds more than once, is refused (InvalidContentException(ContentProblem.childListedTwice)) — which listing was meant cannot be told once the others move, and a duplicate listing is already a flagged row the person can remove; a folder into itself or beneath itself is refused for any entry (folderIntoItself). Then one change per list touched: the destination takes out its own moved entries and puts every moved entry in, in the given order, at the place counted on the list without them (withChildrenRemoved, then withChildrenAdded); every other origin takes out its own (withChildrenRemoved); an entry from Not in the script has no origin to write. Every list is tried dry first (ProjectWriter.checkSave, as an add’s parent is), for refusal only; a list the move would not change is left out, and a move that changes no list writes nothing and publishes nothing. Every decision is taken again once the dry runs’ settling is done, from the view and with no wait of the door’s own before the first write’s door (whose own queue and settle remain) — the gone and twice-listed entries, the beside row, the cycle — since an own write of a list may have landed meanwhile; a list the dry run found unchanged is decided too: a destination that no longer names an entry it held, or an origin found without an entry that lists it again, refuses the move (ChildrenChangedMeanwhileException naming that list) before anything is written, rather than leave a row in no list. The writes are one gesture (§7.5): the destination first — its refusal is the move’s — then each origin once, a refusal of one answered and the others still written, since stopping would leave more rows listed twice; nothing is undone and nothing retried. Each change records the list it made from the document it was given, and the writer’s hand-back is proved against exactly that list, so a write of the app’s own that landed on a list between the dry run and the write is built on, never refused. The answer is a MovedChildren: the destination’s list as the writer handed it back (landed, null where it was not written), each origin’s (originsLanded) or what refused it (notRemoved, its entries then listed twice), the entries moved (moved), those the destination already named — as written, under another spelling or pinned — and so took no second listing (alreadyListed) and isWhole; it is the gesture’s answer, so a publication that fails after the writes carries it (IndexNotPublishedException.answer). Log: folder.childrenMove.start with the moved entries and their origins, the destination, index and beside, the viewer, the film and queued, ….turn, ….refused and ….notPublished as every change of the lists; folder.childrenMove.refusedBecause before a refusal, with its reason (gone, listedTwice, besideListedTwice, cycle, destinationChanged, originChanged), the entry, the list and when (asked, beforeWrite); folder.childrenMove.decided with the place (at), the entries, the origins and the unfiled, alreadyListed and what each list will be (add, reorder, addAndReorder, remove, unchanged); folder.childrenMove.destinationRefused with the hand-back where the destination’s write was refused; folder.childrenMoved with the count, the entries and every list as it landed, and its time; and folder.childrenMoveHalfDone, an error, with the lists that landed, each origin not written with its refusal’s type and message, the entries and the time — both logged before the publication, so a publication that fails cannot hide what landed.

Discarding a version and the pending sets (project_provider.dart, its part sets_provider.dart; Project Specification §7, §12.7). ProjectController.setDiscarded(entity, {viewer, stillWanted}) is the one door for discarding — nothing brings a version back — and runs in the viewer’s commentary queue against the index the previous write left: the writer’s flag (§6.1a), then — discarding the version the viewer’s own pick names — the pick moves to the version’s forked_from where that is a retained version at the epoch and not the official one, else the viewer returns to official by the follow-official rule (the tracking value where a workspace’s owner picked otherwise); no other pick changes, and pickAt is unchanged. Then the parent follows: a folder or scene (childTypes) leaves each of the viewer’s own lists that names it once it is gone — in their script or beside it, as the tree draws them (§5.12: the assistant’s draft’s list where there is one, so that a list the script does not draw, such as the version in use’s under a draft, keeps its entry, which the reader drops), a folder under Not in the script having a list of its own — a short stem whose last retained version this was, or a full reference to exactly this version — through removeChild, never a fork of the parent; discarding one version while another remains leaves the listing as it is, and another author’s list is left to the reader, which drops the entry, and to its author’s next change of that list (parentReason: not-yours). It answers (wrote, pickFollowed, parentFollowed): a pick or a parent that cannot follow leaves the flag written and says so (version.discard.pickFailed, version.discard.parentFailed), as setOfficial answers false. Logged version.discard.start {stem, viewer?}, version.discard.parent {stem, parent, entry, outcome: removed|not-listed} and version.discarded {stem, wrote, reason, pickMovedTo?, parent, parentReason?, parentFollowed, ms}, or its refusal’s reason. PageAccess.canPick and canSetOfficial are false on a discarded version, which resolves for nobody, and the version row’s check and star say This version is discarded: PageAccess is that guard, as it is a plug-in’s, since the writer’s setPick and setOfficial accept any version and the loader reports a pick or a pointer naming a discarded one. pendingSetProvider(type) is the viewer’s pending record of a type (FilmProject.pendingOf) and setHistoryProvider(type) every record of the type that is no author’s pending one, newest first; pendingSetsProvider is PendingSets, the one door for comps, sessions and queues alike: add(type, item) appends with the record’s next n and raises next_item, creating the pending record (its id by nextBatchId) where none is; remove(type, n) keeps next_item, so no n is reused; commit(type, status, members:) writes the status and the type’s own members; pending(type) is the viewer’s pending record as decisions read it (§7.5), for an action that decides before it writes; update(stem, change, {projectKey}) rewrites any record of the film by its stem, not only the pending one — a comp’s posting, posted and its return to pending, a queue’s pass and the job chain’s marks on its items — change answering the new document, or null to write nothing (reason: no-record where there is no such record, no-change where the change declines), and, with projectKey, refused as ProjectNotOpenException while another film is open, so that an operation begun in one film never writes into the next. Each runs in the controller’s queue under the type and the viewer, writes through ProjectWriter.writeSet, one gesture (§7.5); a record changed meanwhile is refused as stale, never retried. Logged set.<op>.start and set.<op> {type, outcome, stem, status, items, ms}.

PageAccess.childrenLock (page_access.dart) says why a version’s children may not be changed — noHandle, readOnly, notYours — and canEditChildren that it may: the form’s own answer (editable) with its reason, since the parent is written as the viewer’s own version or not at all (in development mode, anybody’s). The tree’s menu and the pages read it, so a disabled Add folder… says the same thing everywhere: Set your username in Settings first, This project is read-only here, Fork this folder first… or Fork the project file first…. The structure is content: a director has no special path.

WriteProblemText (write_problem_text.dart), the sentence for a refused or failed write wherever it is shown, moved here from filmopen_forms with the dialog’s move to the tree: the state is below every package that writes and above the store, whose refusals these are.

External entity changes (entity_patch.dart). ProjectController.patchEntity(projectKey, stem, change, holds:) serializes changes by film key and version stem. It refuses projectNotOpen when the key differs or the film is closed, including after queued work and re-reads. An EntityWriteBinding captures the index, viewer and guards before waiting; neither a changed handle nor development mode redirects an already requested patch. An open draft of that exact film takes the change through apply; otherwise ProjectWriter.saveEntity writes from the app’s own write to the file that landed and that the index has not published yet, else the explicitly supplied expected entity of a move, else the index’s, so that the app’s own writes are never a stale refusal of the next (§7.5) while a change from outside the app since the move checked its origin is refused as stale. A change that changes nothing writes nothing. Its three decisions are made on the writer’s hand-back and the record, never on a publication and never on a read of the file (§7.5): the file as written, with its raw text and its project or library origin, is offered to the draft’s own-write adoption check before the queue advances, without requiring a widget frame. Film identity is checked after every await, and outside edits retain their stale-file refusal. Results carry a stable refusal code and the original exception, so children keeps its existing partial-success answers and localized refusals. Detached draft writes are awaited before a direct patch, and children captures the same binding before preflight, creation and both halves of a move. Ordinary form saves retain the current viewer/guards policy but supply their rendered film key: a departed form cannot write into an identical file of a replacement film, nor adopt its re-read.

Application-owned insertion (insertion_provider.dart). ContainerInsertionEnvironment.capture synchronously refuses a closed or loading film; a null machine-settings snapshot — the plug-ins’ settings read again — refuses as settingsLoading at once (§6.13). It captures the film key and index, viewer, guards, locale, media store and byte limit. The adapter calls ProjectController.patchEntity with the captured EntityWriteBinding, copies the JSON inside the accepted holds callback, and returns that copy only when the patch succeeds. Refresh checks the open film key and closed flag.

createApplicationInsertionService constructs the inert engine and environment. main obtains jobsDirectory from the platform and installs the service without submitting work or calling PluginInvocation. Looking up the path creates no folder: Windows uses %LOCALAPPDATA%\FilmOpen\jobs, Linux $XDG_STATE_HOME/filmopen/jobs with the normal home fallback, and macOS/mobile the app-support folder’s jobs/; the web uses no directory. Only durable submission or restore creates the journal folder; session jobs remain in memory.

InsertionAppBinding owns the project/closed/settings listeners and an ExitTasks entry. An actual film-opening transition restores that film, deferred until the host’s settings snapshot is ready; construction and ordinary refresh do not restore. Closing before settings finish cancels the pending opening. Exit removes the listeners and closes the service with a ten-second default grace period, independently of widget disposal.

The window’s exit asks (lib/app.dart, §6.14). Closing the window prepares the open film for leaving first, for up to 3 s (DraftsBeforeExit.quick) out of the exit’s 10 s budget (DraftsBeforeExit.limit); a leaving that takes longer than 300 ms (DraftsBeforeExit.savingAfter) shows Saving changes… (exit.saving, a spinner and the words, no button, not dismissible), closed when the leaving ends, and a quicker one shows nothing. Then, on the desktop, for a film with a sync target (SyncHost.hasTarget, the service’s own targetFor), the leaving decides whether the person is asked before the exit tasks run. Refused, Some changes could not be saved. Close anyway? Otherwise it asks only where something of the person’s is not yet on the target — uploads alone: any upload not yet done, whatever its phase — queued, working, waiting to retry, held for a sign-in or needing a person (SyncAppBinding.pendingUploads: the film’s document and media sends neither done, failed nor cancelled) —, a document send pending (LeavingResult.pending), or a document of the folder whose content differs from the sync ledger’s last agreement (LeavingResult.local, SyncService.localChanges: read from the folder and the ledger, with no call to the target; media carry no agreed hash and are not compared). The leaving compares in a stage of its own, local, after the drafts are written and before any stage that reaches the target, so a slow or failed target never leaves it unread, and once more after a wait that ended, so that what the leaving itself saved and sent is not listed (the first list stands where that read cannot finish); a comparison that uses the whole budget stops the leaving before its lift, and the deferred sends go at the next open. A fetch, or a change on the target not yet known, never asks; a comparison not read (it timed out or failed) after an incomplete leaving is not known to be nothing, and asks; after a complete one the strict pass and the wait stand for it. The app waits for the leaving’s own answer, every stage of which keeps to the 3 s. The sentence says the reason the sync gives, never one it did not establish: signInNeeded (the strict pass’s answer for the account’s missing token from its first refusal, or sends held for a sign-in), Sign in to {domain} to sync your changes. If you close FilmOpen now, they stay on this computer and sync once you sign in.; sends in flight (queued or working), {n} files are still syncing… with one line per file, {name}: {percent}% done, a send held for a sign-in giving the sign-in words; an incomplete leaving whose target could not be reached over the network (unreachable: the cloud’s request or its stream failed; a target missing or not found, unavailable, is not one, and Drive names no network failure of its own), Your changes could not be synced yet (you may be offline). If you close FilmOpen now, they sync the next time you open it.; any other reason, Some changes have not synced yet. If you close FilmOpen now, they stay on this computer and sync later. Under the sentence, the files of this computer not yet synced: {n} file(s) on this computer are not synced yet: and each path — but not under a refused leaving’s question. An open form the leaving saved does not ask, nor do jobs that ended; unsent keeps counting the open form for the background-transfer notice. Keep open undoes the leaving (undoLeaving: the form deferred again) and cancels the exit; Close anyway runs the exit tasks with what the preparation left of the budget, fixed before the question, so the person’s reading time is outside it. No question without a binding or an open film, for a film without a target whatever its preparation, with nothing of the person’s waiting to upload, or off the desktop (Windows, Linux, macOS) — Android’s Back and sleep never request an exit. Logged: app.exit.start {stage: prepare, synced, quickMs}, app.exit.saving {shownAfterMs} when the line appears, app.exit {stage: prepare, outcome, pending, leavingStage, code, savingShown, ms}, app.exit.failed {stage: prepare, type, error, ms} where the preparation threw, sync.leaving.begin {film, withinMs}, sync.leaving.start {film, stage, leftMs} and sync.leaving {film, stage, outcome, code?, error?, ms} per stage, sync.leaving.timedOut {film, stage, pending, ms} where the budget ran out and sync.flush {film, outcome, stage, sent, pending, code, ms} at its end (the code the reason, not the stage), the comparison’s own trail — sync.local.start {film, kind}, sync.local.ledgerRead {film, agreed, ms}, sync.local.folderRead {film, documents, media, ms}, one sync.local.file {film, path, local, agreed: <hash> | never-agreed, differs} per document, then sync.local.compared {film, kind, documents, agreed, changed, ms} (or {film, outcome: noTarget, ms}), or sync.local.failed {film, stage: ledger | folder | compare, error, ms} — and sync.leaving.local {film, local, paths, unread?, reread?, ms} (with sync.leaving.localFailed {film, reread?, error}, a warning, where the comparison threw), sync.flush carrying local, app.exitQuestion.shown {reason: notSaved | signInNeeded | syncing | offline | notSynced, code, preparation, stage, unsent, pending, files, uploads, uploadJobs: [{name, kind, phase, code}], names, percents, local, paths} before the wait, app.exitAsked {reason, code, preparation, stage, names, local, paths, unsent, pending, files, uploads, uploadJobs, choice: close | keepOpen, humanMs}, app.exitQuestion.skipped {reason: nothing-local | noTarget | notDesktop | unmounted, preparation, stage, code, local, paths, unsent, pending, files, uploads, uploadJobs} — the three carrying the same facts of the leaving, then app.exit {stage: tasks, outcome: complete | timedOut, pending, ms} and app.exitRequested {draftsWritten} as the window closes.

A listener disposed during the exit (lifecycle_hook.dart). Flutter asks each observer of the app’s exit in turn, from a copy of the list taken before the first, so an owner disposed while an earlier observer is awaited — the exit above takes a second or more where the leaving is slow — is still asked. An owner that can go while the app runs hears the app through a LifecycleHook, never an AppLifecycleListener, which would throw in a debug build: the share card’s groups (ShareGroups, read again on resumed), the monitor’s views (MonitorViews, the window closed on exit) and the issue recorder while it records. A text box holds Flutter’s own listener, which no hook replaces, so the exit’s own work keeps text boxes on screen: the quit’s close of a chat leaves its panel mounted (§7.4). A hook called after its dispose hears nothing, answers exit, and logs app.lifecycle.lateCall {owner, event: exitRequested | stateChange} once.

An import’s apply (import_apply.dart, §6.18). applyImportProposal(read, reviewed) is the one door the review and the assistant’s dialog call. read is a Ref’s, a WidgetRef’s or a container’s read, and reviewed is the proposal as the person was shown it. One Apply runs at a time, whatever the proposal, and importApplyRunning says when one does. It answers one of three things. ImportApplyRefused(refusal): nothing was written. The refusal is either ImportProposalDroppedException, because the proposal is no longer held or no longer belongs to the film open, its viewer or its development mode (it is dropped), or the reason OpenDrafts.settleOrRefusal gave for an open draft that could not be written (the proposal is kept), or ImportApplyInFlightException, because another Apply is still running: two at once would each plan a fork on the index before the other’s. ImportRedraw(proposal): nothing was written, and proposal is what to show next. Either the proposal held under the id is no longer the one reviewed, checked before the drafts settle and again after, or the folder was re-read after the drafts settled and the plan made again from the document with the stages carried is not sameAs the plan reviewed; the proposal then holds the new plan. ImportApplied(proposal, result): the plan went through applyPlan over ControllerImportWriter. The proposal now carries the stages a fork or a creation made, so that Apply again resumes from them; an update leaves nothing to resume from and carries none. Its plan is what is left to do, made again on a re-read after the writes, so that Apply again after a partial write compares with what the person is shown next. A dialog therefore shows ImportApplied.proposal.plan before it offers Apply again, and passes that instance back: an Apply of the review shown before would otherwise be compared with a plan the person has not seen. A proposal replaced under its id meanwhile is left as it is. After it, the first stem this Apply wrote opens through browserProvider.navigate, unless it is on screen already: the selection names it; or the selection names no version and either a form is open on it or it is the version in use at its epoch (where the selection names an epoch, the same one); or the overview is shown and it is the project file in use. This is the import’s only navigation; the dialogs build none.

ControllerImportWriter implements the exchange’s ImportWriter over the controller’s own doors as the proposal’s viewer. Before every door it checks that the film, the viewer and development mode are still the proposal’s: a film closed or replaced is project_not_open; no handle is no_viewer; another handle, or development mode switched, is viewer_changed. The four writes:

  • An update is patchEntity with expected: the reviewed target and reseed: true. Through an open draft, the change callback first compares the draft’s document with the reviewed before, canonically, with updated aside. Where they differ, it changes nothing: the draft is left as it was, nothing is written, and the outcome is changed_since_review. Before that, a target the index has re-read since the plan was made, which another change’s re-read can do, is changed_since_review without a call. On a direct write the reviewed target’s text binds the write: a changed file is stale_file.
  • A fork is ProjectController.fork(target, content:, film:).
  • A creation is createEntity, then its content through patchEntity against the created file, as re-read, with the file’s header under the reviewed content.
  • A recovery is setPick on the retained artifact, after artifactMatches compared it. A changed artifact is artifact_changed.

A write holds when the re-read file has what the reviewed change changes, as the writer writes it: every top-level key after changes from before has its new value, and every one it removes is gone (a key the person also typed into below it is taken for a refusal, which the next Apply redraws as nothing), with updated aside. What the person typed elsewhere in the file meanwhile does not make the import’s own write a refusal. A fork, a creation and a recovery are done only when the controller’s index shows the stem made in use for the viewer. A stem not yet in use is read again, up to twelve times; a read that was taken settles it, and otherwise the outcome is created_not_picked. An update whose target is gone or comes from the library is artifact_changed, and a write whose re-read does not hold, with no refusal from the store, is changed_since_review. Every other refusal keeps the exchange’s codes, and no exception’s text travels. setPick reads the viewer inside its queue: a handle changed between the writer’s check and the queued pick is a known window.

The script focus (script_focus.dart, §9.3): scriptFocusProvider holds the block open in a scene’s script editor (ScriptFocus {stem, blockId, index, by: person | timeline}), which the editor publishes after the frame that opened or condensed it — an opening checked against the live card, a close naming the block it captured and clearing only the block current —; scriptFocusRequestProvider carries the timeline’s request for a block, numbered so that the same block asked twice is two requests; and timelineChargeProvider says who is in charge of which block is open (TimelineCharge {following, dockPressed}), written from gestures and focus listeners only, never from a widget’s lifecycle (script.focus, script.focus.ask, script.follow).

7.1 The seams for the agent layer — filmopen_state

Section titled “7.1 The seams for the agent layer — filmopen_state”

What the product carries is six seams with no-op or silent defaults, in filmopen_state so that every package above it can reach them and no product package ever imports the agent’s own packages (filmopen_automation, filmopen_mcp):

  • AutomationHost (automation_host.dart) — what an agent may ask: state, navigate, setTab, tap(identifier), setField(stem, key, text), screenshot, semantics, logTail, the doors of the project menu and Settings (openProject(path), setViewer(handle), setLocale(code), setTheme(mode)), and takePickedFiles(), the next file dialog’s answer: null by default, so the upload sheet’s Browse opens the native dialog; the agent build’s host answers what pick_files holds, once, an empty list being a cancel. The recorder’s ops are not on this interface: Recording (record_start, record_stop, record_state) is filmopen_automation’s optional host interface beside SemanticsTaps, MouseInput (click, drag), FileAnswers (pick_files) and AppExit, which DriverAutomationHost implements (§4.3, §6.8). AutomationHost.current is the NoOpAutomationHost (isAvailable false, every answer “not available”) unless an entrypoint outside lib/ — the agent build, test_driver/agent_main.dart — installs another before main runs; main builds the ProviderContainer and hands it to whichever host is installed (attach). AutomationHost.rootKey is the RepaintBoundary that app.dart wraps the page in, so a host can take a screenshot from the render tree on every platform; AutomationHost.windowKey is the one app.dart builds above the navigator (MaterialApp.builder), the whole view with every route, dialog and menu, which the issue recorder shoots and which page_shot now paints too (§4.3, §6.8).
  • FieldRegistry (field_registry.dart) — the forms open for editing, by the stem of the file each edits. A stem may hold several setters, each answering its own keys — a scene’s form its fields, and the script editor inside it blocks.<id>.<field> — asked in turn until one answers; stems lists a stem once while any setter is there. A FormSide registers its own setter when it makes its saver and leaves when it drops it, and the script editor registers its own only while it is editable and leaves when it is not (both by identity, so a departing one never removes a live one’s entry; unregister(stem) with no setter removes them all). An agent’s setField(stem, key, text) finds the field the form names by that key and calls the field’s own setter, FormFieldSpec.set — the same function its onChanged runs, so an age becomes a number under appearance and a year of birth "N (YYYY)" there, an empty name is an inline error rather than a removal — after putting the text in the box; the autosave, the identity lock and the stale refusal then hold for an agent as for a person. A control that takes no text (the kind pull-down, the genre picker, the epochs editor) declares no setter, and it, like a key no field owns, answers false.
  • OpenDraft.stem, OpenDraft.pending, OpenDraft.apply and OpenDrafts.of(stem, projectKey:) (drafts.dart) — how a change made below the forms reaches a file a form is open on. A folder or scene added to, removed from or moved in a file’s children is the controller’s write (§7), and the file may be the one on screen — the project file’s page, a folder’s. Written beside the draft, the change would be kept out of a dirty draft (§9.3: an outside change while dirty is kept out), whose next autosave would then be refused as stale, and Discard and re-read would lose what was typed meanwhile; re-seeding a clean draft instead resets every box under the person’s hands, trailing space and caret included. So there is one writer per file while its form is open: OpenDrafts.of(stem, projectKey:) answers the draft registered last for that exact film and file, and apply(change, key:) takes the change as the draft takes a keystroke and writes it now (DraftSaver: change then settle), answering what refused the write — a failed state, nothing less: settle also answers false when something was typed during a write that landed, and the saver’s error keeps an older failure after a later success. No box is reset, the recorder hears one draft line for the key, and FieldRegistry is untouched, since no field owns children. With no form open the registry is empty and the controller saves the file as it is on disk. DraftsNotSettledException is what an action that writes beside the drafts throws when settleOpen answers false, as a fork says finish saving first. pending is the whole document the draft would write while it has anything to write, and null when it has not, the file on disk then being what a write starts from: what a change made below the forms is tried dry against, so that the check and the write are made against one document (§7). snapshot is a deep copy of the current document, including a write in flight and later typing. Reading it never settles, adopts or mutates the draft. An insertion environment can capture a proposal’s baseline synchronously through it; pending retains its existing meaning. OpenDrafts.settleOrRefusal settles every draft as settleOpen does and answers why one could not be written — its own error where the draft is a DraftRefusal (the form’s saver is: its failed write’s error), else DraftsNotSettledException — or null where every one is clean; settleOpen is its yes or no. OpenDrafts.settleUntilQuiet settles pass after pass while a pass leaves a draft to write — one opened while the pass wrote, or typed into during or after its own write, which is no refusal there — each pass waiting for every write in flight (OpenDrafts.track: a saver’s own as it starts, and one a form gone left behind), up to maxSettlePasses (five), answering a pass’s refusal (a draft’s own error, or DraftsNotSettledException for a draft that gives none), DraftsNotSettledException where the last pass still left one, else null: what a change of username waits for (§9.4). OpenDrafts.idle says nothing is open and nothing is being written, so that a change of username with nothing to write first is made at once (§9.4). EditableDraft is an open draft a card below the forms edits as the page’s own — a sub-interface of OpenDraft, so that the drafts that only settle stay as they are: change(apply, key:, reseed:) takes a mutation as the draft takes a keystroke, the autosave writing it in its turn, and listenable notifies whenever the draft changes, typed or applied.
  • OpenDrafts.changes, writesPending, writesSettled and openIn(projectKey) (drafts.dart) — what the sync hears (§6.14). changes is a Stream<DraftChange> of {projectKey, kind: added | removed | moved}, heard after the change: add and remove emit theirs (removing a draft no longer open says nothing), and DraftSaver.entityChanged calls moved when its form moves to another file. writesPending says a tracked write is in flight; writesSettled fires each time the last one ends. openIn says a form is open on a file of that film.
  • DraftObserver / DraftObservers (drafts.dart) — onChange(entity, key, value): the saver tells the observers each change with its key (null when a copy or a block edit touched several) and, since the issue recorder needs to hear what changed and not only that it did, the draft’s value at that key, deep-copied once per observer so an observer can never write into a draft or into another observer’s copy; the value is null when the key is null. OpenDrafts in the same file is the set of drafts a form holds open, which a fork and Copy to a folder… settle through this seam without knowing the forms.
  • RouteEvents (route_events.dart) — one NavigatorObserver app.dart installs on the app’s navigator, silent until something listens: a dialog, a menu or a sheet opening and closing, named by its kind from the route’s own type (never its type’s name, which a release build minifies) and by its name when it has one. The issue recorder is its one listener; a failing listener is logged by its error’s type and never reaches the navigator.
  • PluginRuntime (plugin_runtime.dart, §6.11) — the plug-in runtime as the pages, Settings, Provider keys and the agent layer see it, in the state’s own types (PluginSummary, PluginSection, PluginField, PluginKeyOwner, PluginKeySlot, PluginProvider, PluginCallControl, PluginCallOutcome, PluginKeyCheck): the plug-ins, the key slots, a type’s sections, the providers of a hook, the changes Settings → Plugins makes, the calls — runAction, runEntityHook, call and verifyKey — and changes, a Listenable. PluginRuntime.current is NoPluginRuntime (no plug-in exists) until lib/main.dart installs the host, which no product package imports. The seam is deliberately the whole of what the app asks of plug-ins, and its types are the state’s own — a page’s view of a plug-in, resolved and in the app’s language — apart from the manifest’s types in filmopen_plugins (kept as on 17 September 2026): the screens, the forms and Provider keys import neither the host nor the plug-ins package, and a fake runtime tests them; a plug-in feature therefore touches the seam, the host and a screen.
  • DeviceRegistration (device_registration.dart) — where the account says who is signed in, for the device’s registration (§6.6b): signedIn(DeviceAccount), signedOut, registers (whether a sign-in here would register at all, for the account popup’s sentence), registeredDevice, and the device’s permanent name — slug and claim(slug), answering ok or a code (unavailable by default) — a no-op answering false until lib/main.dart installs filmopen_device’s DeviceRegistrar off the web, so that no test and no agent build reaches a store or the network through it. Not the agent layer’s, but a seam of the same shape: the account does not know the device package, nor it the account.
  • ImportProposals (import_proposals.dart, §6.18) — the import proposals waiting for the person, and the exports an operation keeps. An ImportProposal carries its id, the document, the base, the origin (plugin, the plug-in’s tag as its door passes it; function; operationId), the film’s key, the viewer, the guards, when it was proposed, the plan as reviewed, the stages written and whether the person put it aside with Later (deferred, kept by withReview). The seam has add, replace, defer, discard, current(film:, viewer:) and of(id, film:, viewer:), and changes, a Listenable. A proposal of another film or viewer is dropped when it is read, and every one goes when no project is open, so no listener is needed for a film left, a handle changed or a project closed. retainExport and exportOf keep an operation’s exports. release(operationId) ends them, with the operation’s proposals, when the operation is cancelled, fails or ends with nothing to propose. An operation that ends with a proposal keeps its exports until that proposal is discarded or dropped: the last proposal of an operation takes its exports with it. replace(proposal, expected:) leaves one replaced meanwhile as it is. defer(shown) flags the proposal a review showed and moves it after every other held one. It logs import.defer.start (its id, plug-in, function, film and viewer, the flag the proposal shown had, its position and how many are held), then its decision with what it decided from — whether the proposal held under the id is the one shown (held), another (replaced) or none (gone), the held one’s flag, the position it was at — and its time: import.deferred with the position it moved to and how many held are put aside, under either development mode; one let go or replaced since it was shown is left as it is, logging import.defer.ignored with gone or replaced. Every proposal let go without the person’s decision — dropped at read by current or of, or released with its operation — logs import.dropped with its reason — other_film (before other_viewer where both changed), other_viewer or released — and the proposal’s id, plug-in, function, operation, film, viewer and whether it was put aside; a release of exports alone logs none, and a discard is the person’s. The document, the base and the exports are held as frozen copies; the plan’s own maps are the exchange’s.
  • Semantics identifiers on the controls an agent drives (§13): what a recorder writes into a tape and replay acts on, so the product never contains a line that says “if recording”. The walk that finds a control by identifier or by label and names the one under a point (filmopen_state/semantics_walk.dart) is shared by DriverAutomationHost and the issue recorder, so both name a click’s target the same way; selectionToJson (selection.dart) is shared the same way, since the tape writes a selection too.

Beside them and of the same shape, compPosterProvider (comp_poster.dart, §6.17) is the comp poster — prepare, publish, check, abandon, progress, cancel over the state’s own records CompPosting and CompPostOutcome — whose default answers every call not available until lib/main.dart installs the app’s composition off the web.

None of these starts a listener, takes a dependency or changes what a person sees; filmopen_state/test/seams_test.dart proves the no-op host does nothing, that the registry and the observers route as described, and that the route seam is silent with no listener and names what a listener hears; import_proposals_test.dart proves the proposals’ seam empty and silent until used, dropped at read and released with its operation; test/identifiers_test.dart proves the identifiers are unique and the registry’s typing reaches the box.

The seam by role (H6). PluginRuntime was one abstract class of 25 members for Settings, consent, keys, entity hooks, platform calls and the developer page, which every package that touched any of it had to take whole. It is four interfaces it implements, over one they share: PluginCatalogueView (what is installed and what it says of itself — the plug-ins, the key slots, a page’s cards, a hook’s providers, the contract’s revision, a plug-in’s file and string, and what is known of whether each can work, readiness), PluginSettingsControl (what the person may change — on and off, Allow, Try again after turning on or a check did not succeed, a grant, a setting, a preferred provider, an install, the development folder), PluginInvocation (what may be run — an action, Check, an entity hook, a key check, a platform hook, the model picker’s defaults, and a prompt: the writers for a kind, a prompt written by a named writer — a plug-in’s tag and the writer’s id — or the first of them, each writer offered with its text model and rate where it asks one, §6.11) and PluginDevBench (the developer page’s own road). PluginRoleBase carries the two every caller shares, isAvailable and changes; attach belongs to no role, since nothing but the entrypoint calls it.

One object and one static: the role split left PluginRuntime.current and NoPluginRuntime in place, so the fourteen test fakes that extend it needed no edit. What changed is that a package names the role it uses: each role answers <Role>.current, and filmopen_forms, filmopen_keys, filmopen_plugin_dev and filmopen_plugins_ui name no more than the roles they call — a test reads their sources, outside their comments, and fails on any that names the seam whole, or the base, which every role already answers. What still names PluginRuntime is what installs one: lib/main.dart, the host, and the fourteen test files that install a fake. lib/screens/settings_page.dart holds PluginSettingsControl at every one of its sites, the base’s two members among them — which plugin_roles_test.dart pins as the set of roles that file names.

PluginSettingsControl.machineSettingsSnapshot supplies a nullable deep copy for binding new work. The host answers null until the latest reload generation commits its settings, even if an older map remains in memory. A superseded load cannot replace or hide the latest ready snapshot, which is published before change listeners run. NoPluginRuntime answers an empty map. The entrypoint passes this getter to insertion; no state-to-host dependency or PluginInvocation call is added, and the private values never enter a journal or log.

7.2 Account, provider and native-service contracts

Section titled “7.2 Account, provider and native-service contracts”

The integration services are abstract installation points with current, install and inert defaults. Installing one starts no account, credential, plugin, preference, audio or network work. Each changing source publishes its snapshot before its change stream signals. Stable installation notifications let a provider observe a replacement even after its first read. The providers subscribe in build, re-read the installed snapshot on change, ignore notifications from departed sources and cancel their subscriptions on disposal. Funding and native-session admission never run during provider build.

AccountSnapshots supplies an immutable AccountSnapshot. Authentication is authenticated, signed out, loading or unavailable, independently of the local author and the saved sign-in hint. Only an authenticated snapshot may carry a known balance. A balance is known, unknown or unavailable; unknown and unavailable have no amount. A known balance carries an integer amount in minor units, the host-supplied ISO 4217 currency code and its UTC observation time. Public serialization is an explicit allowlist with no e-mail, account ID, token or ledger. Diagnostic string conversion prints only authentication state. The default account source is unavailable.

ProviderFunding.funding(providerTag, operation) returns an own-key route, an explicitly implemented managed-credit route or a typed unavailable reason. A funding query places no hold and submits no work, so options and activation may query without paid admission. A successful result carries a nonsecret opaque FundingBinding retained by identity for the job’s provider and operation; changing credentials or accounts cannot redirect collection. Own-key verification takes precedence, and an invalid own key is not permission to spend credit silently. The default resolver returns not configured. Its observable generation changes with credential, account and configuration bindings. Four members serve an operation on credit, each a concrete inert default on ProviderFunding that the account overrides: portalHeaders(platform, operation, operationId) answers the headers a request on credit carries — the bearer and the portal credential of the operation’s scope, one credential per operation — or null; portalRefused lets a credential the website refused go, so the next gesture asks anew; receipt reads the operation’s receipt, null until it has settled; end ends the operation, once. operation is the operation’s kind — http (kPluginHttpOperation), conversation (kConversationOperation), transcription (kTranscriptionOperation) — and operationId the operation, a UUID its caller minted. The credential’s headers reach the host’s HTTP door on credit, which sends them to the website’s pass-through and nowhere else (§6.11); the default resolver answers none.

MediaClient.upload(bytes, contentType) turns bytes into a URL a provider or another device can fetch, answering a MediaObject — the object’s key, its download URL and when that ends — or throwing a MediaRefused with a code and, where the website answered, its status. The default NoMediaClient refuses media-not-configured, sends nothing and logs the refusal; lib/main.dart installs the account’s AccountMedia (§6.6, The media client) off the web on the session’s access token. Nothing is sent until a consumer uploads.

Deadline is a pure admission-inclusive operation budget with a UTC start, an absolute deadline instant and nonnegative remaining time. A child consumes the parent’s remaining time and cannot extend its deadline. Cancellation is idempotent and flows to descendants, never to ancestors or siblings. There is no timer or platform listener. Server discovery has a named thirty-second whole-operation budget, independent of insertion settlement and lease staleness.

MicrophoneOwnership is the single arbiter above native capture. Its default is one pure in-memory instance, which opens no audio. Each capture attempt receives a fresh named MicrophoneOwner; acquiring an occupied microphone reports the exact owner and never steals, even for a repeated request by that owner. Only the exact owning attempt may release after cleanup, so delayed cleanup cannot clear a later capture’s ownership. Replacing an occupied installed arbiter is refused before its installation changes; reinstalling the same instance is inert. Disposing an occupied in-memory arbiter is also refused, preserving its owner’s eventual cleanup release. Replacement or disposal follows completed cleanup and release, never a guessed end of capture. The owner getter supplies the initial state and an ordered stream publishes transitions. The microphone-owner provider observes both installation and ownership changes.

MediaEntryActions is an initially empty, ordered registry of per-entry hooks. Each action supplies the last identifier segment, localized label, icon, applicability, typed availability and selection callback. The menu passes its captured entry and immutable project, entity and access context. Write access and plugin permission are separate facts from PageAccess; read actions may apply to a nonwritable entry, while library and read-only project plugin restrictions still hold. Local refusal reasons distinguish access, unsupported or absent media, busy work and stale selection; a provider refusal retains its own typed route reason. Selection revalidates the captured entry and permissions before execution. Registration returns an idempotent remover for exactly that occurrence, and consumers observe immutable registry snapshots without invoking actions. Neither forms nor a media widget is imported into state.

NativeConversation.open(binding) captures the provider, compatible conversation model, voice, audio format and funding choice without connecting or acquiring a microphone. Explicit Start admits only a ready, funded session, with no cap of the app’s on its length and without automatic reconnection. Caption revisions retain stable turn IDs and partial/final distinctions; usage events retain frozen provider units without inferring confirmed money or settling credit. End and Close immediately tear down resources while retaining the transcript, without finalization or project writing. Finish first stops capture, commits pending audio and stops spoken output, then drains already-admitted caption and usage events for at most five seconds with sockets and listeners still open, admitting no new speech responses. It then closes resources and freezes a transcript with explicit completeness before a separate finalizer. End or Close may win a drain race; binding and ownership remain held until End has awaited its releases. Partial captions remain visibly incomplete. The default service and sessions answer typed unsupported results with empty streams and start nothing.

7.3 The command registry — filmopen_state/command_registry.dart, core_commands.dart, entity_commands.dart

Section titled “7.3 The command registry — filmopen_state/command_registry.dart, core_commands.dart, entity_commands.dart”

CommandRegistry is the one list of what the app offers a model: the in-app voice conversation, the relay behind Remote control and the text assistant (CommandCaller voice, relay, text). Like the other seams it is static with inert defaults — no commands, no session, NoCommandValidator accepting everything, an empty authority — and starts nothing. A Command has a source (builtin, or a plug-in’s tag) and a name, each ^[a-z0-9][a-z0-9-]*$ and together at most 64 characters; a title and a description; a raw JSON Schema inputSchema whose type is object, an optional outputSchema; meta, the MCP tool’s opaque _meta, at most 8 KiB; the flags exposed, effectful, readOnly and destructive; codes, the fixed refusal codes its handler may answer; and its handler. register refuses by throwing CommandRegistrationException with name, duplicate, not-object (not an object schema, or an effectful command whose schema lacks session and operation), schema-unsupported (the injected validator cannot read the schema) or meta-too-large; unregister(source, [name]) removes; changes notifies both. toolOf(command, separator:) builds the MCP Tool: the name <source><separator><name> (. by default, as the relay publishes it, §6.6c), the schemas, _meta, and the annotations readOnlyHint, destructiveHint and, for an effectful command, idempotentHint.

Dispatch. call(source, name, caller, args, {deadline, guard, assistantSession, assistant}) gives the call a local reference c-<n>, the only id the log carries, and answers a CommandResult — MCP content items, structured, _meta, isError — for every outcome, never a throw. In order: the command exists (unknown-command); a relay caller reaches only an exposed command (not-exposed); the bounds — arguments at most 16 deep, with no size of the app’s, and an effectful command’s operation ^[A-Za-z0-9._-]{1,64}$ — then the schema through the validator, both bad-args, with structured.problems as path and code — the bound (:depth, :unencodable, operation:pattern) or the validator’s problems (path and code, never a value, though a path can name a key the caller sent); a deadline already passed (deadline); the ledger for an effectful command; the handler, a throw answered failed; a result goes whole, with no size of the app’s, and command.call.handler.done logs it by its value with its milliseconds. A refusal is isError with structured.error and a text item carrying the code. The handler gets a CommandContext: the reference, the caller, the arguments (unmodifiable, control fields included), the deadline, and the authority — CommandRegistry.authority() captured once at the call, an opaque digest of account, viewer and film the root builds with AppLog.digestOf.

The operation ledger is in memory, per app run, keyed by operation alone, with two states: admitted, reserved before the handler starts, and answered, the result kept. For an effectful command: a missing session, or an empty registry session, is session-required, another run’s stale-session — the first call included, with no effect; a missing operation is operation-required. The fingerprint is the command’s source/name, the canonical JSON of the arguments without session and operation, and the captured authority. No entry: each new operation first drops the answered entries older than an hour — an admitted entry is never dropped, and no count bounds the ledger — then an admitted entry is added and the handler runs. An admitted entry with the same fingerprint: the call waits and receives the same answer. An answered one: its result, nothing run. Another fingerprint — other arguments, another command, a changed account, viewer or film: operation-mismatch. admit(), which a handler calls immediately before its effect, rechecks the deadline, the caller’s guard and the authority, and answers deadline, stopped or authority-changed; once it has passed it stays passed, and the answer is kept whatever follows — a throw after it is kept as failed, so the key never runs twice. check() answers the refusal admit() would, synchronously, and admits nothing: a read-only handler calls it before outward work. An empty session is read as missing. A success counts as an admission, kept whole; an entry whose handler refused without passing admit() is removed, and every waiter receives that refusal. Nothing persists, retries or reconciles. CommandRegistry.instructions(separator:) is the paragraph a catalogue carries for a model: the session, the two control arguments, builtin.session (named with the separator, . by default), and that a repeated key returns the first answer while it is under an hour old.

The core commands, registered by registerCoreCommands(container), all read-only and exposed. builtin/session {} answers {session} and the instructions as text. builtin/navigate {id} acts through a person’s doors — the rail to the project page, then browserProvider.navigate: resolveCommandEntity(project, viewer, id), public for other commands, answers the entity a full stem names — exactly that version, and not-found for one the film lacks, never another version — or, for a versionless exchange id at an epoch, the version in use there (FilmProject.pickAt); navigate opens it, or a group id’s entity as a whole, as its tree row does; anything else is not-found; a closed film is no-project. It answers {id, stem, name, type} and raises a CommandObservation (ref, caller, source, name, outcome, and a label, the entity’s name, shown and logged): the dashboard turns navigate’s ok into the transient message {who} opened {name} — The assistant or Remote control. builtin/export {scope, arg?} answers exportScope (§6.18) for the viewer as structured and as the document’s wire text, whole, with no size of the app’s; its scope enum is scopes() at registration; an ArgumentError — an unknown scope, an argument of the wrong shape — is bad-args, an unresolved tag a problem inside the document, a closed film no-project; problems names scope or arg; for an argument shape, expected says what arg was — absent, a string, a number, a boolean, an array of strings or not, an object with its keys — and what to send: no arg, a JSON array of the scope’s typed ids or tags with an example such as ["sc_1"], or for focus an object of folders and scenes, each such an array. arg’s schema is a description alone, naming each scope’s shape with an example and no type, so that the registry’s validator passes any shape to the handler: there a string that reads as JSON to a list or an object is read as that, once, and logged command.export.argDecoded {ref, from: text, arg, to}; any other value goes on to the scope’s check as it came. The same expectation is logged with the scope and the argument as asked. Beside the exchange’s scopes, {scope: "entity", id} answers one entity at one epoch as exportEntityDocument(lean: true) projects it, identified by $id without $stem, read from the working version below — the version a change would be made from, which a selection across the film never reads (they read the versions in use); a full stem or a versionless exchange id names the entity and epoch, never that version (versionsNamed), and a missing id is bad-args, an id naming nothing not-found, an entity with no version decided for the person no-version, no viewer no-viewer; where that working version is deleted (Project Specification §7) the answer is {deleted: true, stem} with no content, and the live pick is never put in its place, since it is not the version a change would write. A group id answers {group, epochs}, each epoch’s working document in declared epoch order, then undeclared epochs in natural order. A deleted epoch is {epoch, deleted: true, stem}, an undecided epoch {epoch, "no-version": true}; neither substitutes another version. Group writes still answer not-found, so a caller changes the desired epoch by its returned $id. The group export logs its resolution, type, epoch count, ordered working and base reason codes, deleted and undecided counts, bytes and elapsed time, with the id asked and the document answered. Other export scopes remain full exchange documents. A command’s title, description and the instructions are English for a model, never localised. The nine built-in tools have compact descriptions carrying their call and answer rules: entity changes send only changed keys, scene blocks change by id, deletion moves to the recycle bin, the person picks the assistant’s version, and a failed answer naming a stem is never resent. The export description distinguishes entity‘s id from tag scopes’ arg, with a scene_dialogue list example. Schema shapes are unchanged by description text.

The assistant’s write path, registered by registerEntityCommands(container): four effectful, exposed commands, the same for every caller — voice, text and the relay. The assistant writes as the person: no handle of its own. Its changes land in the person’s draft, a version of their own the assistant made, labelled ai: and a summary of at most four words, which the person picks when they want it — never picked by the assistant. The one exception is builtin/declare-epoch, below, written at once on the version in use, as the person’s own Add an epoch is. Writer authorization stays the person’s: the controller writes as the viewer, writingAs refusing any other handle passed as as: (write.asRefused {reason: not-viewer, as, viewer}); the writer’s exact-author, identity and stale checks are unchanged. A fork or a creation made with a draftLabel (fork(…, draftLabel:), createEntityAs(…, draftLabel:), addChild(…, draftLabel:), createOutfit(…, draftLabel:), the writer’s ProjectWriter.fork, createEntity and createOutfit) carries that label, validated as any version_label (at most 32), and writes no pick: entity.forked / entity.created say picked: false and the label written; every other fork and creation is picked as before. Existing <owner>.ai files stay another author’s versions. Nothing here sets official.

The working version (workingVersion(project, viewer, versions)) is where the assistant’s next change of an entity at an epoch lands. The draft (assistantDraftOf) is the viewer’s newest own version at that epoch where its label begins with ai: (assistantDraftPrefix) and it is neither the viewer’s pick nor official; where there is one, the change lands in it in place (WorkingBase.round, reason round); otherwise it forks the person’s version in use as the person into a new draft (WorkingBase.inUse, reason no-draft), the entity null where nothing decides one. No state is kept: the person’s pick of the draft, the director’s star on it and any newer version of the person’s each end it, so an older ai: version is never written again; going back on the page writes nothing and keeps it; a label typed by hand that begins ai: reads as a draft. The label (assistantDraftLabel) is ai: and the first four words of the call’s summary, cut at a word boundary to 32 characters (a single longer word cut at the limit), ai: draft where none is given; it is set on the version a write creates, and a write in place keeps the draft’s label.

builtin/update-entity {id, content} changes one entity. In order: the content held to the entity’s own type’s record as the view reads it (below), so that a refusal settles no draft; admit(); the open drafts settled (OpenDrafts.settleOrRefusal, drafts-unsettled); the entity re-resolved (versionsNamed) in the film as decisions read it, the app’s own landed writes included (§7.5); the working version; before a fork, where nothing explicit decides the person’s version in use — their own latest (own), or the sole owner’s (soleOwner: any entity a collaborator has no version of with nothing official, and the project file, whose handles are counted, §4.1) — that version pinned as their own pick (pinPick, the writer’s own pick, never the controller’s setPick, which follows official), written just before the fork once a change is sure to follow, admission rechecked in its queue and the person’s version re-resolved there — a pick of theirs that landed while the pin waited, or a version no longer decided so or no longer the one prepared, refuses the update stale (command.pin {ok: false, reason: pin-changed}) with their commentary untouched — so that the draft, the person’s newest version, is not in use until they pick it, and a second owner never makes their resolution ask (§6.1 step 3); an official or picked version is never pinned, since a pick of official would go stale when it moves (§13.2) — the cost of the pin is that an official version set later makes it stale, asked on the page, rather than winning in silence; the merge; the write — in place into the draft (saveEntityAs), unless the record holds a pick or a pointer naming it, in flight or landed unpublished (ProjectController.inUseRecorded, §7.5), in which case the draft is forked as the version in use it is about to be, the pin skipped (reason: in-use-recorded, with working, the view’s own reason, recorded: pick | pointer and landed); else a fork of the version in use with the merged content and the label in one write (fork(…, content:, draftLabel:)), both as the person, the fork not picked, admission rechecked just before the write, inside the commentary queue for the fork and the pin (a gate the controller runs there), so that a change of account, viewer or film while the write waited refuses it unwritten — and, for the fork, the working version re-resolved there too, so that a second call on the same entity that waited behind the first (a model’s parallel calls) is refused stale rather than forking past the draft the first made, a draft the record still names in use counting as in use there; the write’s one publication, inside its stage (§7.5) — a write that landed whose publication failed is failed {stage: publish, stem}, written and not to be sent again, its write kept for the next publication; then verify: the working version in the film as decisions read it must be the stem written, as the draft, else failed {stage: verify, stem} — said, never repaired. The content merges into the entity key by key: an object given into an object (or where there is none) merges recursively, so a call sends only the keys that change; null removes a key at any depth; a list, a string or a number replaces whole; a key absent stays; command.update.stage’s merge line logs the merged entity, written or unchanged, beside the content as handed (command.update.start), and creation’s content line the entity as written; refused reserved (keys naming them): the filename’s keys, epoch only where the filename carries one (a scene’s is content, §10.2), and author, v, forked_from, created, updated, version_label, label, by, source, next_block, epochs on a project file (the answer adds use: builtin/declare-epoch, so that a model neither drops nor invents an epoch; a person’s editor and an import do not go through this command), and deleted on a project file, which is never deleted. An entity’s deleted is the recycle bin’s (Project Specification §7): true deletes the entity in the version written, as any key would, so that it is absent for whoever has that version in use while its file stays; null removes the key, restoring it; any other value is bad-args (content.deleted:value); create-entity never makes an entity deleted (reserved). A scene’s blocks is a list of changes by id (mergeBlocks, §5.13a): {id, …} updates that block as the content does, {id, "deleted": true} removes it with its number never given out again, {"insert": "top" | "bottom" | "<id>", …} adds a block after the anchor under the next number, blocks sharing an anchor in the order sent; an item with neither key or both, a deleted other than true and an id two blocks carry are bad-args, an id naming no block unknown-id — all or nothing; a block’s output_duration_sec is written like any other key when it is a number above zero, cleared by null, and anything else is bad-args. The content is held to its type’s record (contentProblems(type, content, patch: true), §5; Project Specification §18.3) — the resolved entity’s type for an update, the requested type for a creation — before any merge, pin, fork or write, recursively: a key the record does not name, at the top, in a block (the block’s record, §10.3, beside the change’s own insert and deleted) or in any nested record, and a value of another shape are refused bad-args, one problem per path (content.blocks[1].type:notAllowed, content.synopsis:type), the answer and the last line carrying type and shapes — each path, its code, the value given, the shape expected, the keys allowed there and, for a prompt name the place does not take, the advice that says which records take it and the names this one takes (promptAdvice: prompts.first_frame is a scene block’s; a character’s prompts are reference, sheet, voice.) and, for a top-level key a character does not have, where such facts go — notes or voice.description, voice.lang being one language code — (appearance_notes is not a character’s field; the languages a character speaks, habits and other notes go in notes or voice.description; voice.lang holds one language code, the voice’s own.), so that a model keeps the fact rather than dropping it — so that the caller sends it again; a key outside the record goes under the free-form metadata; null deletes at a merge boundary, an old unknown key included; nothing is rewritten, and a key already in the file is not this write’s (§18.2). A content that is no object is bad-args content:type, its shapes naming the value given and the object expected. The check is the handler’s, not the registry’s: each write tool announces content as an object pointing at update-entity’s description ({"type": "object", "description": "The shapes are in update-entity's description."}), and update-entity carries in its description one generated guide to the nested shapes a change may hold, to every type’s top-level keys and to every field (contentGuide: a block, a cast entry, a prop entry, a prompt, a media entry, the project’s format, then every type a command writes with its change view’s keys — notes, creator_notes, media and prompts among them — and its fieldShapes lines, then the prompt names each place of it takes, from the records — prompts.reference|sheet|voice: a prompt on a character, visual_style.prompts.treatment: a prompt on a project —), which create-entity‘s names instead of repeating it, so that text, voice and the relay’s callers all receive them, once, in the tools themselves, and its checkedByHandler makes the registry’s schema check take any value there (command.call.schema logs handlerChecks), so that the answer names the exact type’s paths. A tool argument the schema does not take is refused bad-args with, beside its problems, the keys taken there (allowed: {<path>: [keys]}, CommandAllowedKeys, the runner’s session and operation left out), logged on command.call.schema. The merged document is validated (invalid); a scene’s references that the change sets or changes — location, cast[].character, cast[].outfit, blocks[].character, props[], style, outfits[], the typed resources[], shots[], cues[], each compared with the same place in the scene before the write — the same field, a block’s speaker by the block’s id — so that one left as it was is not checked, and a new one is though an equal value stands elsewhere — are resolved by FilmProject.resolve as the person, at the epochs the format gives them (Project Specification §6.1, §10.2: location_epoch and a cast entry’s or a prop’s own epoch explicit, an outfit at its character’s resolved epoch, a speaker at its cast entry’s, anything else at the scene’s or the default implicitly), and one that resolves to nothing is refused bad-args before any write or pin, a problem per field (location:unknown, cast[0].character:noEpoch) and unresolved naming the field, the value and the tags of that type the film has, so that the caller sends the tag, as both commands’ descriptions say (a resources item its typed stem, a full stem pinning a version) — never stripped or guessed, an exchange $id such as lo_flat being no reference; a cast character missing at a declared epoch, in a write whose content carries epoch or cast (the one that makes the cast’s versions), passes where seedSourceFor gives the version the write makes there from, and is refused with the seed’s reason (seed in unresolved) where it does not; in any other write it is refused like any other reference (castVersions: notInThisWrite); every decision is the last line’s references (the field, the value, the epoch and whether explicit, the stem and any fallback, or the problem, and a cast character’s seed; one left as it was, skipped: untouched), a refusal’s problems and unresolved beside it; one that changes nothing is not written and answers mode: unchanged. There is no version check: the writer’s refusal of a changed file stands (stale); a fork whose base could not be read at all is no changed file but failed {stage: read}, the writer logging the read’s error by type (fork.readSource). Refusals: no-project, no-viewer, read-only, not-found, no-version (the entity is there and nothing decides the person’s version — a project file the reader asks about included), reserved, bad-args, unknown-id, invalid, drafts-unsettled, stale, not-yours, the registry’s own, and failed {stage}. An optional summary names the change for the label; command.update logs label: summary|default on a fork and kept in place. It answers {id, stem, v, mode: inplace|fork|unchanged} and raises an observation labelled with the stem written, which no message shows yet. It is announced destructive: in the draft a key or a block is removed in place. Its description and create-entity’s name a block’s keys, with a spoken line and an action, and say that type, kind and text are not among them, the records themselves riding on content; its description tells a model what mode means, what to do on stale and drafts-unsettled, and that a failed naming a stem was written; a $ key — an export sent back whole — is reserved. Where nothing at all decides the person’s version — a project file the reader asks about — the answer is no-version. For a character, only content carrying birth_date fills numeric appearance.age against this version’s declared era. A null removes the key and keeps age, including a legacy age, with noBirthDate; a malformed text stays written and age is kept, with badBirthDate when appearance permits a fill (appearanceShape takes precedence for a non-object appearance); a value that is no text is refused by the record (content.birth_date:type). A summary-only or other unrelated write never refills age. The merge trail carries birth_date, epochYear, decision/reason and before/after ages beside the merged document.

builtin/create-entity {type, name, tag?, kind?, epoch?, parent?, character?, content?} makes a character, a location, an outfit, a prop, a style, a misc, a document, a scene or a folder — never a project or a workspace — as the person’s v1, a draft labelled from its optional summary and not picked (label logged as on update), under the creation screen’s rules: a library entity’s tag a short name its type does not have yet (tag:required, tag:shortName, tag:taken), at a declared epoch or the project’s first (epoch:undeclared); a scene numbered after the others (nextSceneTag), a folder likewise (nextFolderTag), its kind the folder’s word stored as given — episode, season, act, an arc, anything (Project Specification §10.1) — and absent where none is given, its children reserved on creation so that a new folder starts empty, filled by a move; the parent of either a folder or the project file (parent:type); an argument that does not apply to the type is bad-args (<key>:notForType). An outfit is made through its character’s own door, the one New outfit uses (createOutfit(…, draftLabel:, gate:)): character, the wearer’s tag, is required (character:required) and must name a character of the film (character:unknown), and it takes no tag, kind or parent; its tag is the door’s allocation, <character>.<owner>-<n>, answered as tag beside id, stem and v so that a default_outfit can name it; command.create logs the character and, for every creation, the tag made, and its create stage line the stem. The content is merged into the seed — the header, the name and the kind, a scene’s blocks as insert items, their keys checked as on update — and validated before any write (reserved, invalid, unknown-id, bad-args), a scene’s references resolved as on update (bad-args, references on command.create). Then admit(), the drafts settled, the film rechecked, and the seed written (createEntityAs(…, draftLabel:), no pick written), admission rechecked at each write after its waits — for a scene through the add-child door (addChild(…, gate:)), in the commentary queue before the seed, and before the listing in the parent’s patch queue after its wait and settle (patchEntity(…, gate:), ahead of the draft taking the change, which would otherwise autosave it); a scene or a folder is listed in its parent’s children written as the person (addChild(…, draftLabel:): the child the person’s draft; the parent its working version — the parent’s draft where it has one, written in place as any later change of it, so that picking the draft keeps the scene; else the version parent names, the person’s version in use for an exchange id, that very version for a full stem, or the project file in use where it names none; logged into: draft | in-use) — and a parent of another author’s is refused (not-yours) by the command itself, in development mode too, never forked; a listing that fails leaves the scene under Not in the script and answers listed: false, and a listing refused by admission (a call stopped, the authority changed) answers that refusal with {stage: list, stem, listed: false} — the scene kept, nothing undone. The content is a second write, as the import’s creation makes one, inside one pass of publishingOnce with the seed (project.hold.end {gesture: builtin/create-entity}), so the film is published once and both receipts are told after it, each heard with the entity whole; a publication that fails after them answers failed {stage: publish, stem}, logged command.create.failed {ref, stage: publish, stem?, type, cause, error}. Where the content fails the command answers failed {stage: content, stem, outcome: created-without-content}, the version kept and nothing rolled back. Then verify, as above. It answers {id, stem, v}, with listed for a scene or a folder. The new project’s placeholder. After a scene is made, listed and verified — never where its content or its listing failed — the untouched first scene a new project is made with (isUntouchedFirstScene, filmopen_store’s project_template.dart: sc_1 v1 with exactly the template’s eight keys, created equal to updated) is set aside through setDiscarded, never deleted, where it is the only version of its scene and the viewer’s own; the command’s admission is asked again first, and the same checks again inside the discard’s queue (stillWanted), so an edit that lands meanwhile keeps it. A scene’s answer carries placeholder {outcome: discarded | kept | failed, reason, stem?, error?} — kept for notListed, absent (the stem then names the discarded one where there is one), notYours, forked, edited, notAdmitted:<code>, notWritten, official, viewerChanged or changedMeanwhile:<what the queue's recheck found>, the recheck logged with the versions and the scene as it then stood; failed for pickNotFollowed, parentNotFollowed or error — and a failure keeps the scene made, the create still ok, nothing retried or undone. Logged command.create.placeholder {ref, made, stem, versions, author, viewer, listed, keys (with their values), expectedKeys, created, updated, outcome, reason, error?, ms}, an error line where it failed, then the discard’s own version.discard.*. The person’s own Add scene never discards it. A character’s content stage applies the same birth-date fill against the settled project’s declaration for the created version’s era; its content trail carries the same age metadata.

builtin/declare-epoch {label, year?} | {periods: [{label, year?}]} declares a period of the story — or, with periods, each period in order through the same path, all in one call: the schema checks only that periods is a list of objects, each period is checked by the handler (periods[<i>].label:missing, periods[<i>].label:blank, periods[<i>].year:type, periods[<i>].<key>:notAllowed with allowed), a refused period is answered in its place and the rest still declared, the answer {periods: [{index, epoch, relabelled, draft?} | {index, error, …}]} — a period written whose pick failed keeping its failed with epoch and stem and ending the writes there, each later period answered in its place {index, error: unrun, reason: periods[<i>]:failed} —, a Stop, a deadline or a changed authority ending the list there and refusing the call with the periods answered so far; logged command.declare-epoch.periods.start with the list, each period’s lines with its index, and command.declare-epoch.periods {count, declared, refused, outcome, answers, ms}; periods beside label or year, or empty, is bad-args — on the project version in use, through ProjectController.declareEpoch’s binding and queue (§8.2), as the person’s Add an epoch does, written at once inside attributed: each period a chat declares is an item of its own in the chat’s record (kind: epoch, below), never the project file’s patch, so the person’s own project file is never an item of the review; an own version is saved, another author’s forked and picked (the fork its item, and the period another). In that queue, after admit() again (_gateOf, as the other entity writes; a Stop or a deadline passed while the drafts settled refuses with nothing written), the decision reads the film as it stands: a label already declared, trimmed, answers its token and writes nothing (reason declared), so that an action run twice does not declare its period twice; otherwise, where the sole declared epoch is soleUnusedEpoch and its label is still initialEpochLabel in any language, the app’s language now whichever, it is relabelled, its token, order, floor and other fields kept and its year set, or cleared where none is given (epoch.relabelled {epoch, stem, label, year, was}, relabelled: true, reason untouched-default); otherwise a new epoch is appended (epoch.declared {epoch, stem, label, year}, reason not-sole, used or renamed). An entity takes the token of the period it belongs to, the first included where it is not the project’s default; one without an epoch is at the default. So a used default is never relabelled, nor one a person renamed, and of two periods declared in turn the first relabels and the second appends. It answers {epoch, relabelled}, with draft beside them as below. Refusals: bad-args (label:blank, year:type), no-project, no-viewer, read-only, no-version, drafts-unsettled, stale, not-yours, invalid, the registry’s own, and failed {stage}, with epoch and stem where a fork landed and its pick did not (written: not to be sent again). The person’s AI draft of the project keeps in step, whichever comes first in a turn, the draft or the period: where the period was written and the person has a draft of the project (assistantDraftOf, above), the draft’s entry for that token is set to the entry as written, its label and year, a relabel included, or the entry is appended where the draft lacks the token; so nothing is duplicated, the order is renumbered by encodeEpochs, and an append carries the next_epoch floor as the writer’s own declaration encodes it. The write is the draft’s in-place save, saveEntityAs inside attributed after admit() again: a patch receipt on the draft’s stem, folded into the draft’s item and set aside with the draft on Discard. A draft written is answered draft: <stem>; a draft that cannot be written leaves the call ok and the period on the version in use, answered draft: {error, stage}, the stage resolve, admit or write, and is not retried, since declaring it again would write nothing; where no draft needed writing there is no draft. Its limits: where the version in use is another author’s, the declaration forks and picks it, the fork is the person’s newest version, and an older draft is no longer the draft: nothing is written to it; a label already declared writes nothing, so a draft forked before that declaration stays short of it, as does a draft whose write failed, and picking either loses the period from the declarations, the files staying as they are (§14); and a period a model itself names with the default’s own label leaves the default looking untouched, so that the next period replaces it. The log: command.declare-epoch.start {ref, caller, label, year}, command.declare-epoch.stage {ref, stage, ms}, command.admit, epoch.declare.decided {label, year, stem, epochs, usage, relabelled, reason}, the writer’s line, command.declare-epoch.draft {ref, index?, inUse, token, declaration, stem?, v?, before?, after?, outcome, reason, ms} — outcome written (reason relabelled or appended), none (reason declared, no-draft, no-project or same) or the refusal’s code with stage, type, error and osError? —, command.declare-epoch {ref, label, year, inUse, sole, usage, epoch, relabelled, reason, draft, outcome, stage?, ms}, and command.declare-epoch.failed {ref, stage, type, error, osError?}.

builtin/move-entity {id, parent, position?} moves a scene or a folder — id by exchange id or full stem, naming the entity, never a version (not-found for one the film lacks, id:type for any other type) — into parent, a folder or the project file (not-found, parent:type), at position: top, bottom (the default) or the exchange id of a sibling to go after, counted in the destination’s list without the entry itself (position:notInParent where the destination does not list it). It is the assistant’s door to the person’s move (§8.5, ChildrenController.moveChild), through the same list edits (withChildAdded, withChildMoved within one list, withChildRemoved) and the assistant’s one write path — the write update-entity makes, the same code: the working version, the pin, the merge, the write in place or the fork, its publication, verify — never the person’s in-place write of a parent’s file. Moves run one at a time: a move waits for the move before it to end, its cycle check and both its writes, since two moves checked against the same lists at once could each pass and together write a cycle (the relay sends calls as they come); one chain for this command alone, no other command waits on it, nothing is retried, and the wait is logged (queued). Then, in order: admit(); the drafts settled; the entry resolved, in the film as decisions read it (§7.5), (versionsNamed) and the destination resolved; a folder into itself or into a folder beneath it refused invalid (folderIntoItself) before any write, read through the working versions of the folders on the walk — the lists a move writes, where the person’s own move reads the versions in use (folderIsOrHolds) — so that a folder moved out of another in the drafts may take it, and a cycle two moves would make in the drafts is refused; the destination’s working version taken (no-version where nothing decides one); the origin found — the first working version of the project file in use or of a folder whose children names the entry’s short stem, a list other than the destination preferred so that an entry listed twice is taken out of the other, the entry’s text as written, so a pinned reference moves pinned — none for an entry under Not in the script; then the destination’s children written first into its working version — the person’s draft in place, else a fork of their version in use into a new draft labelled from summary — and the origin’s second, re-resolved after the destination’s write and written the same way; within one list a single write. A change that changes nothing is not written: mode: unchanged where the entry is where it was asked to go; where only the origin changes (the destination named it already) the move is the removal alone. A refused or failed first write answers its code with parent: destination: nothing written but the pin, or, failed at its verify, the destination’s draft (answered with stem), the entry then listed twice as below; a refused or failed second write answers its code with parent: origin and stem, the destination written (and originStem where the origin’s write answered one at its verify; an origin that cannot be re-resolved after the destination’s write is failed {stage: resolve, parent: origin, stem}, logged with its reason), the entry then listed twice — reported by every reader (childDuplicate), visible, never lost — and nothing undone or retried. It answers {id, mode: moved | unchanged, destination: {stem, v, mode}, origin?: {stem, v, mode}}, each mode inplace, fork or unchanged, and raises the observation on a move; the dashboard opens nothing for it.

The root’s wiring (lib/command_wiring.dart, once from main): the validator, an adapter over filmopen_plugins’ JsonShape (a schema it cannot parse schema-unsupported, never its text); the session, a UUID v4; the authority; the core commands, the engine of Auto-generate pictures handed the insertion service under the app’s attention projection (§6.15), logged command.wiring {stage: autoReferences, insertion, projected} — insertion the service’s type, projected: false where no projection is installed and the engine reads InsertionService.current. A throw there is logged command.wiring.failed {stage, type, ms, commands} and not rethrown: the app goes on starting, with the commands registered before it, nothing rolled back or retried. The debug layer (filmopen_automation, filmopen_mcp) never learns the registry.

The log (§6.2): command.register.*, command.unregister.*, command.call.start {ref, caller, source, name, args, assistantSession?, assistant?} (the session among them: an identifier, not a credential), lookup {found, source?, name?}, authorized | not-exposed, bounds {argsBytes, ok, reason?}, schema {ok, problems}, deadline, handler.start, handler.done {resultBytes, outcome}, command.call.done {ref, outcome, ms}, command.call.failed {ref, stage, type, error}; command.operation {ref, state, operation}, command.operation.settled {ref, kept, entries}, command.admit {ref, ok, reason?}, command.check {ref, ok, reason?}, command.ledger {entries, dropped}; command.navigate {ref, id, resolved, type, stem, name, reason, ms} (id as it was asked, on every line; stem and name the version and the name found; reason the refusal’s code), command.export.start {ref, scope, arg, id} (what was asked, as handed) and command.export {ref, scope, arg, id, members, problems, skippedDeleted, bytes, document, ms} (skippedDeleted: the entities the recycle bin kept out, a count, §6.18; document the answer whole, as the model reads it; a refusal {…, reason, problem?, expected?} with the scope and the argument it refused) — for the entity scope {ref, scope, id, resolved, type, base, working, stem, bytes, document, ms}, with outcome: deleted and the {deleted, stem} answer as its document where the working version is deleted —, command.update.start {ref, caller, id, content} (the arguments as handed, before any cast or refusal), command.update.stage {ref, stage, ms} through admit, settle, resolve, merge, pin, write (its publication included) and verify, fork.readSource {stage, type} (the writer’s, a fork’s base unreadable), command.pin {ref, needed, was} as it is decided, then {ref, ok: true, ms}, {ref, ok: false, reason: pin-changed, was, base: same | moved} where the queue found the person’s version changed, or {ref, ok: false, type, osError?} where the write failed, command.update.requeued {ref, working} where the fork’s queue found another working version, command.update {ref, resolved, type, deleted: 0 | 1, restores?, base: round | in-use, reason, working?, recorded?: pick | pointer, landed?, mode, label: summary | default | kept, blocks: {updated, removed, inserted}, reserved, problem?, problems?, checked, shapes?, verify, outcome, stage, ms} (checked the type the content was held to) (deleted: 1 where the change asks to delete the entity, mode saying whether anything was written; restores: 1 where it sends deleted: null), command.update.failed {ref, stage, type | working, error, osError?}; command.create.start {ref, caller, args} (the arguments as handed, the content included, before any refusal), command.create.stage {ref, stage, ms} through check, admit, settle, create, list (with ok and, where it failed, the refusal’s type), content and verify, command.create {ref, type, parent: named | project, into: draft | in-use, epoch: given | default, label: summary | default, blocks, reserved, listed, partial, problems, checked, shapes?, outcome, stage, ms}, command.create.failed {ref, stage, cause, type | working, osError?}; command.move.start {ref, caller, id, parent, position} (the arguments as handed), command.move.stage {ref, parent?, stage, ms} through queued (its ms the wait), admit, settle, resolve and, per write and tagged parent: destination | origin, the update’s stages (resolve, merge, pin, write — its publication included —, verify), command.pin and command.move.requeued likewise tagged, command.move {ref, id, parentId, position: top | bottom | after, after, resolved, type, to, from: <stem> | same | none, into: draft | in-use, destination: {base, reason, mode, label, …}, origin: {…}, problem, mode, outcome, stage, ms} (to and from the stems of the working versions found; problem the refusal’s reason, a safe code — id:type, parent:type, position:notInParent, folderIntoItself — never a value; a refusal’s last line carries the write’s parent), command.move.failed {ref, parent, stage, type | working | reason, error, osError?} (error: the failure’s text whole, its path included; osError: the operating system’s error code a file system failure carries, osErrorCodeOf — 5, an I/O error, 28, a full disk, on Linux — beside it), write.asRefused {reason, as, viewer}, command.wiring.*. A logged outcome — in handler.done, call.done and command.observe (with the observation’s label) — is ok, or the result’s structured.error as it is; the caller’s answer is unchanged. A registration, a refused one included, and an unregistration log the source and name they were given. A command’s own lines carry the values it worked on — its arguments as the model handed them, the stems it resolved and wrote, the content merged, a refusal’s problems, a failure’s type and message — so that a wrong shape or a failed write is read from the log alone (§6.2); the dispatch lines above carry the call’s reference, outcome and sizes, and the chat’s id and the assistant’s (assistantSession, assistant). Nothing here is a credential.

Automatic cast versions. ProjectController.createCastVersions(scene, {film, viewer, draftLabel}) is the scene’s character-only companion door. It reads each cast[].character, never block-only speakers or locations; the cast entry’s epoch overrides the scene’s, which otherwise falls back to the default. An occupied target, including discarded versions, is skipped. For an existing character group with no version there, seedSourceFor chooses the default era’s version in use, else the sole era with retained versions; several candidate eras, no pick, no version or no viewer are honest per-character refusals. createAtEpoch(clear: true, draftLabel:) binds the source stem and raw bytes and uses the shared seed: look, outfit and pictures cleared, voice retained, age filled by the seed’s rule (§6.18). It takes the supplied film and viewer through the controller’s queues without settling open drafts. A missing draft label keeps the writer’s ordinary pick behaviour; an assistant label makes an unpicked draft. Each attempted character answers {character, epoch, name, label, outcome: created | refused, stem?, reason?}; an unlabelled era has a null label. A writer refusal keeps its binding reason or exception type, the rest continue, and the helper’s future never completes with an error. An unexpected failure logs scene.cast.versions.failed with its stage and full error, without undoing the scene save or retrying. The trail’s .start, .character, .created, .refused, .skipped and .done carry the scene document and cast, film, viewer, source and target values, candidate eras, decisions, outcomes and elapsed milliseconds. As an exception to the ordinary createEntity route above, the controller’s import adapter routes a seeded change through createAtEpoch(clear: true) using the source era’s pick and its raw bytes; a missing source is artifact_changed/noSource, and the existing content merge follows the creation.

After a scene’s update-entity write, in place or forked, and after a scene’s create-entity content stage, content carrying epoch or cast asks this helper before verification. Other scene content logs scene.cast.versions.skipped {reason: unchanged} with its values and time. The companions take assistantDraftLabel(summary) for either update path and the creation’s label for a create. The helper runs under the command’s attribution, so its writes enter that chat’s review as unpicked drafts; its outcomes join castVersions in the command’s facts and final log. A refused companion does not reverse the scene write.

7.4 Assistant Sessions: every write heard, the chat’s recorded — filmopen_state/assistant_session_ledger.dart

Section titled “7.4 Assistant Sessions: every write heard, the chat’s recorded — filmopen_state/assistant_session_ledger.dart”

The receipt. Every write door of ProjectController — fork, saveEntityAs, createEntityAs, createOutfit, createAtEpoch, declareEpoch, addChild’s new file, patchEntity (every list edit of the script goes through it), pinPick, setPick, pickFromCheck, keepPick, abandonPick, selectProject, setOfficial’s follow and setDiscarded — publishes one EntityWritten {stem, kind, projectKey, author, before?, after?, from?, to?, attribution?} for each write that landed, and none where nothing was written (a change that changes nothing, a follow with no pick to remove). kind is fork (a new version of an entity that had versions), create (a new entity’s first version), patch (an in-place write of a version that existed, before the version as it was) or pick (the viewer’s pick, from their own pick before and to the version now picked — official’s on a return to official, null where nothing is official). after is the document the writer wrote: ProjectWriter.written, set by its save, its creates and its fork beside what each answers, so a receipt never reconstructs a stamp or reads the file again; the discard’s likewise, from ProjectWriter.written as setDiscarded set it. A receipt is built as its write returns, before any publication or verification, and published when its door ends — whatever followed, a later refusal included — each logged entity.written with the documents as they are and its ms, then handed to the one WriteObserver (writeObserverProvider) and awaited: a write returns only once it was heard. An observer that throws is logged entity.written.unheard and the write stands. A bound patch’s receipt is patchEntity’s, whether its change went through the parent’s open form or the file, so one change is one receipt; a later save through the write context a form’s saver kept is the person’s, published on its own (entity.patch.lateSave).

The attribution. Attribution {command, session, operation, assistantSession, assistant, summary} is a Zone value: the trusted source/name of the command, the app run’s command session, the call’s operation, the chat’s own id (the host’s action-<µs>-<n>), the selected assistant’s id and the command’s summary argument. A write door reads it at its entry. The assistant host sets CommandContext.assistantSession and assistant when it hands a tool call to the registry, never from a model’s arguments; the relay, the voice conversation and the agent layer set none. A command enters it with attributed(context, write) around each write it authors — the pin, the in-place save, the fork, the three creates, the content write and builtin/declare-epoch’s period and its draft’s entry — and around nothing else: the settle, the admission, the resolution and the verification stay outside. A form’s own save is never attributed: DraftSaver writes with the attribution cleared (Attribution.cleared), wherever its flush runs, inside a command’s write or not. And before a change from below reaches an open form (OpenDraft.apply), a writer of another identity still pending refuses a bound change at once, nothing written or waited for, and again after the settle where another change installed its writer meanwhile (draft.apply.refused {reason: conflict, at, pending, incoming, inFlight, ms}, the two identities compared); otherwise the saver waits for a save of the person’s already in flight — a bound write in flight of the same identity is extended, not waited for — and writes what they typed, as its own write, again while typing during that write leaves more pending (draft.apply.settleFirst {outcome, writes, ms}), then writes the change before answering, through the detached path where the form went away meanwhile, so the change — an assistant’s listing — is a write of its own whose before holds the typing, and undoing it never removes what the person typed. Each attributed receipt is logged session.attributed {stem, kind, session, operation, assistantSession}.

The broadcast. entityWrittenProvider tells the creates, patches and forks that landed, in order, to whoever listens (EntityWrittenBroadcast.stream; its one listener is Auto-generate pictures, §6.15). A receipt is held until a gesture ends, so that a listener reading the film finds what was written: the end of another gesture may tell it before its own publication, where ProjectController.view already holds it, and a pass of publishingOnce tells at its end, with its one publication. A gesture whose publication was skipped or failed tells too, so a listener checks that the film is still open. Logged entity.written.told {writes}, and entity.written.untold {reason} where the controller was disposed.

The ledger. AssistantSessionLedgers, the default WriteObserver, keeps an AssistantSessionLedger per chat id, made at the chat’s opening in a film (opened), which writes its record at once — status: open, no item, an empty history, the film and the viewer open then, session.origin {session, when, action, entity, level, where, reason} — and awaited by the dock before the chat starts: a chat whose close began meanwhile — no longer held, or its close begun — starts nothing, whether its record was written or not, that close disposing it (session.open {outcome: closing, reason: not held|close begun, recorded}), and an opening whose record cannot be written does not start, and the dock says This chat could not be recorded in the film, so it did not start.; a chat opened with no film open has no record (session.record.none {session, reason}), and a chat not opened here has its ledger made by its first attributed write (session.started {assistantSession, run, assistant?}). A person’s write is no session’s. One item per uninterrupted run of the chat’s writes of an entity, its shape fixed by its first write — a fork with its base (the version forked from), a create, a patch with its before — and every later write of the same stem moving its result {updated} on and nothing else; no hash is kept, and a record that has one is read as it is. An unattributed write of a stem in this app closes its item in every live session — the person’s, or the relay’s, the voice conversation’s, the agent layer’s (session.item.closed {session, stem, n, reason}): an item covers one uninterrupted run of the chat’s writes, so its before never holds an edit made in the app after it, and the chat’s next write of that stem opens a new patch item whose before is the document as the person left it. An item also keeps the command, the operation, the draft’s label and the command’s summary of its first write, at and outcome: open. A later chat writing a draft an earlier one made has an item of its own, a patch with its own before. A pick is no entity: it goes on the record’s picks [{entity, from, to, at, operation, item?}], item the item a later write of the same entity under the same operation made — a pin before a fork the queue then refused leaves a pick and no item. A command refused after some of its writes keeps them. Logged session.item {session, stem, kind, n, result.updated} and session.pick {session, entity, from, to, at, operation?}.

The record is the session’s as set (Project Specification §12.7): kind: session, status: open, the items, next_item, started, run (the app run’s session), assistant_session, assistant, action and entity — what the chat was asked: its catalog action’s id and, for a field’s or a form section’s action, the entity it opened at, both read from the chat’s own request (AssistantSession.request, held since opened), never from its writes, whose first may be of another entity, and absent for a chat not opened here — picks and messages (The history, below), its id allocated as the pending sets allocate theirs. It is written whole at the opening, at each turn’s two edges, after every event and at the close, in the film the write was made in, through ProjectWriter.writeSet in the pending sets’ queue of the viewer, against the text it was last written with, and awaited (session.recorded, its fields under The history). The first content item recorded — the first write of the record that holds an item, never one of picks alone and never a write that failed — tells the dock once (onFirstItem(session, stem), session.first.item {session, stem, item, n, kind, told}), which moves to the record’s page as §6.11a says. A record write that fails leaves the entity’s write standing and is logged session.unrecorded {stem, assistantSession, entity, kind, cause, error, ms}; what it would have written stays in the ledger and goes with the next event, and the open chat says so: its problem becomes unrecorded, A change was not recorded in the session, until its next turn (session.notice {session, stem, code, shown}; a chat already closing is not told).

The history. The record keeps the chat’s conversation, messages (Project Specification §12.7): AssistantSession.history, the person’s words with at, each turn’s tool exchange as the host keeps it to resend (AssistantReply.exchange: the tool_calls messages and the tool answers, as far as answered, whatever the turn’s outcome — a failed or stopped turn’s tools that ran included), and the assistant’s final words with at; never words only shown, the passing note, the system prompt or the context; a chat not opened here has no session to read, and its record no messages (assistant.session.history {session, phase, turn, exchange, words, error?, messages, ms}). It is written whole with every write of the record, the history captured when the write is asked, so that a write queued behind another or released after the close carries the words it was asked with; and at each turn’s two edges, awaited, through the callback the ledger gives the session (AssistantSession.recorder): the person’s words are written before the model is told them — a record that cannot be written sends nothing (assistant.session.unsent {session, turn, input, reason, ms}), says Your message could not be recorded in this chat’s history, so it was not sent., and puts the words back in the box, before any typed since and a blank line, so that neither is lost (assistant.composer.restore {session, restored: into an empty box|before newer words, text, newer, reason}), and a Stop while they are written tells the model nothing (assistant.session.notSent) — and the turn’s end writes the reply and the exchange, a failure being the chat’s unrecorded notice, once, the next edge writing the whole history again. No listener, no retry and no size limit: a long chat’s record grows with its tools’ answers, each rewritten whole. Each write logs session.recorded {stem, kind: open|send|turn|<a write's kind>|close, action, entity, items, picks, status, messages, last, bytes, ms} or session.unrecorded {…, kind, messages, last, cause, error, ms}. One turn-end write is no journal: a kill before a queued write lands loses that turn’s end.

The close. The dock registers each chat it installs (AssistantSessionLedgers.opened, session.opened) and the session ends before AssistantSession.dispose closes its transport: the dock takes the chat off the screen at once and disposes it when close(session, reason) answers. A chat ends when the person closes it by its X, the chat leaves on its own (applied or discarded) or the dock goes (closed), when a Discard on its record’s page closes it (reviewed), and when its film is left (never by a move, another chat or another assistant chosen): ProjectController‘s leaving, run by Close project and by opening another film, closes every chat (film_changed) once the sync’s preparation of the film has not refused it, while the film is still open and writable; an open the preparation refuses closes none, and the chat goes on recording in the film it stayed in, while Close project leaves the film whatever the preparation answered and closes them; the record’s last write follows that preparation, so it is sent with the film’s next pass — and when the app quits, an exit task closing every chat (quit). One close per session, a second request answering the first’s future, in this order (session.close.start {session, reason, items, busy, leftMs, workLeftMs, recordSliceMs, told}): the turn is cancelled; the session’s own send and the host’s tool call in flight (AssistantSessionBackend.inFlight) are awaited until the record’s slice before the close’s deadline — 500 ms (assistantCloseRecordSlice) kept for the record’s last write, so that work that runs out still leaves the record closed within the deadline, and a deadline shorter than the slice gives it none (assistant.session.settle {turn, tool, leftMs}, session.close.unsettled where they outlast it) — one deadline per close, the earliest any caller asked of it (a later request logged session.close.again {session, reason, withinMs, shortened}), a person’s close 30 s (assistantCloseLimit); the record is written once more with ended, status: closed and the summary, awaited until the same deadline (session.close.record {session, leftMs}; session.closed {session, stem, items, picks, summary, recorded, outcome: recorded | unrecorded | timeout, settled, ms}, timeout where the deadline came first: the write goes on in the film it was asked in, its queue refusing it once that film is left, and its end is logged session.close.late {session, stem, recorded, ms}; an unexpected failure session.close.failed {session, type, error, ms}, the ledger let go all the same); the ledger and its before documents are let go (session.ended {session, reason, wrote, recorded, ms}). A chat opened with no film open has no record. The summary is the items’ summary words, each once, in order, never their labels: a close asks no model, whatever its cause, and where the items carry no words the record has no summary (session.summary {session, reason, words, summary, ms}, reason the close’s cause). A film change or the quit during a person’s close holds it to their own deadline where that is the earlier — what remains of the leaving’s 10 s, or 5 s on quit (assistantQuitLimit) — and cancels a turn begun since (session.close.hurried {session, reason, withinMs, shortened}), the items’ words recorded; closeAll returns once every ledger is let go (session.closeAll {reason, chats, withinMs, ms}), and says while it runs that the film is being left (leaving, its reason), so that the dock installs no chat opened meanwhile (§6.11a): the deadline bounds the wait and the record’s last write. The dock hears each new close as it begins, once it is kept and never for a request that joins one (onCloseBegun, §6.11a; told on session.close.start says whether a dock listens): a close begun elsewhere takes the chat it names off the dock, shown or held, told its reason — except the quit’s, whose chat stays mounted until the app ends (session.closing {session, cause: quit, current, kept: true}), since Flutter still asks every exit observer listed when the exit began, a disposed text box’s among them, which would throw (A listener disposed during the exit, §7); the ledger closes its session and its record all the same, and its transport ends with the app. The chats live here are published at each open and each end, a close whose record failed included (assistantLiveChatsProvider, session.live {cause: opened|unrecorded|ended, session, live, published}; nothing is published once the ledgers are gone at the app’s end): the tree’s Chats calls a record whose assistant_session is not among them archived (§8.2), whatever its file’s status says. A receipt heard after its session’s close is logged session.late {session, stem, kind} and not recorded: a tool call that outlived the bound is written in the film but on no record.

A period’s item. The ledger records a declaration that wrote (WriteKind.epoch, EntityWritten.epoch) as one item per token — {n, stem: the project version written, kind: epoch, epoch: the token, before?: the entry it relabelled, after: the entry as written, command, operation, summary?, at, outcome}, before only for a relabelled untouched default — and a label already declared, which writes nothing, as none. Its Discard (SessionReview, below) reads the film as it stands: the project version gone or set aside took the period with it, and so did a token no longer declared there — recorded discarded, nothing written (decision: version-gone|version-discarded|not-declared); a period a retained version is at — by its filename, its content’s epoch, its location_epoch or a cast or props entry (FilmProject.epochsOf), a discarded version counting for nothing — stays, refused epoch-in-use with the item open (decision: in-use, the users); otherwise an appended period is removed and a relabelled one given back its entry before, through saveEntityAs as the person, stale where the file changed outside the app (decision: removed|restored, the entry written) — and first the same in every other version of that project the record holds as an item and that still declares the token, the round’s draft whether picked meanwhile or not, one set aside left as it is, so that what the row says is true of the timeline the person keeps; a draft that refuses leaves the version in use as it was and the item open, and the next press writes what is left — answered discarded with the code period-removed or period-restored so that the row says which. Each is logged session.discard.epoch {stem, item, epoch, before, after, drafts?, writing?, decision, outcome, code?, users?, entry?, ms}, users the usage checked, empty where nothing is at it, each draft’s change session.discard.epoch.draft {stem, item, epoch, draft, v, was, entry, decision, wrote, ms}; a Pick of a period is skipped by a pass and refused alone, no-side. Discard all takes the periods last, newest first, after every other item, so that a period is asked about once what the chat made at it is set aside. A period a person also set something at by hand stays.

The review — filmopen_state/assistant_session_review.dart. SessionReview (sessionReviewProvider) is the one door of a record’s Pick and Discard: pick(record, n, side), pickAll(record), discard(record, n) and discardAll(record), each answering per item {n, outcome, code?} — picked, discarded, refused or skipped. A Pick writes the viewer’s pick alone: no item outcome, no close, so the person can pick back and forth between an item’s two sides. Each item is guarded as it comes, against the record as it is then: refused in-command inside a chat’s command, not-yours on another author’s record, session-running for a Discard while the record’s session is live in this app (AssistantSessionLedgers.isLive, the ledger kept; a Pick runs while it is) — so the review page closes a live session before a Discard (§9.3), through AssistantSessionLedgers.closeSession(id, reason), which answers close’s future for the chat open under that id, starting that close (session.close.byId {session, reason, found}) and at once for none (session.close.noChat) —, no-record for a record the film lacks (a pass over one answers no item, nothing: empty), no-item for an item it lacks; an item discarded is refused decided by the single forms and skipped decided by the all forms, so an item discarded while a pass waited is never acted on, and a legacy accepted item reads as open, for Pick and Discard alike, never rewritten; the presses on one item take turns (session.review.turn {stem, item, verb, how, side?, waits}), a Discard’s entity write and its outcome one turn, so the second of two at once finds the first’s outcome — where the index already holds it: a re-read overtaken by another can leave the index a moment behind the record. The entity is written first, as the record’s author: setPick and setDiscarded take that author as viewer and, in their own commentary queue, refuse a username changed meanwhile (ViewerChangedException; viewer-changed, nothing written). A Discard’s outcome after it through PendingSets.update, written only while the item is still open: a decision that came first is never overwritten, the answer’s code decided (session.review.decided). Where that write fails the entity write stands, logged session.review.unrecorded, the answer’s code unrecorded (which wins over pick-not-moved) and the item still open: the next Discard finds the version set aside, writes nothing to it and records the outcome. A version no longer in the index is not-found; a file changed on disk since the folder was read is the item’s stale (the writer’s StaleFileException), nothing retried and the pass going on. Pick takes the side’s real file — left the item’s stem, right a fork’s recorded base (a patch’s before is no file and a create has nothing beside it: not-found, reason: no-side) — and is decided by that version’s selection state, not the item’s kind, and in the pick’s own queue: setPick(version, viewer:, unlessExplicitInUse: true) writes nothing only where pickAt lands on that version and the viewer’s own pick or official names it, or where the version was discarded meanwhile — a Discard queued first; in every other case — a fork, a create, a draft in use only as the viewer’s own latest or the sole owner’s, an official version under a pick of another — it picks, which ends an assistant draft (§7.3), and follows official where the version is official (the viewer’s own pick removed); a discarded version is refused discarded, since nothing would put it in use. Pick all takes every item’s left in order of n, skipping one gone (not-found) or discarded, each skip’s code its own. Discard goes through the one door: a fork, a create, or a patch of the round’s unpicked draft (assistantDraftOf, the version a later chat wrote into) is setDiscarded(entity, viewer:, stillWanted:): the item’s decision is read again in the write’s queue, against the record as the index the previous write left holds it, and an item discarded there is refused NoLongerWantedException, the item decided, nothing written; the version is set aside as it is now, no hash compared; a version discarded already is recorded discarded with nothing written; another author’s version, which only a hand-edited record names, is refused not-yours; a flag setDiscarded did not write is not-written; a version official names is refused by the writer, official-first; a pick that could not follow answers discarded with the code pick-not-moved, a list that still names it parent-not-updated; a create its parent lists leaves that list with it. A patch of a version in use the round did not fork — a parent’s listing — writes nothing: it is recorded discarded (reason: follows-children), since the listing follows its children’s discards; a patch of any other version no longer the round’s draft — picked or made official meanwhile, or a folder or project file still labelled as the assistant’s (ai: …) — is refused in-use, nothing written and the item open. Nothing is restored. Discard all takes the items in reverse order of n, a refused item kept open and the rest continuing; a listing patch, being later than its create, is recorded first, so a create refused after it leaves the listing naming it with the patch already discarded. Logged: per item, before its guards, session.pick.start / session.discard.start {stem, item, entity?, kind?, how, side?, viewer, author, session, itemOutcome?}; session.pick.press {stem, item, entity, kind?, how, side, stemPicked, resolvedBefore, resolvedAfter, viewer, outcome, code?, reason, wrote?, ms}, the version in use before and after the press; session.discard {…, ok, outcome, code?, found?, wrote?, reason?: flagged|follows-children|already-discarded, pickFollowed?, parentFollowed?, version_label?, picked?, official?, newest?, ms}; a guard’s refusal or skip {…, outcome: refused|skipped, code, reason, stemPicked?, viewer, author, session, itemOutcome?, ms}; the pick’s decision version.setPick {stem, viewer?, unlessExplicitInUse, inUse?, reason?, ownPick?, official?, outcome: picked|followed-official|explicit-in-use|discarded|viewer-changed|gone|stale|failed, current?, type?, error?, ms}; session.review.all.start {stem, how, order, viewer, author, session} and session.review.all {stem, how, done, skipped, refused, nothing?, viewer, author, session, ms}; session.review.decided {stem, item, outcome, current}; session.review.unrecorded {stem, item, outcome, cause, error?}; an unexpected failure session.review.failed {stem, item, entity, verb, side?, stage, type, error, ms}, rethrown. Not guarded, on purpose: a Discard of a version edited or shared since sets the whole version aside.

7.5 The write path: the record, the publication, the gesture — filmopen_store/project_writer.dart, project_loader.dart, filmopen_state/project_provider.dart

Section titled “7.5 The write path: the record, the publication, the gesture — filmopen_store/project_writer.dart, project_loader.dart, filmopen_state/project_provider.dart”

A write re-derives the index from what it wrote and one listing of the folder; only an open and the person’s Re-read files read the folder’s documents whole. One file is read apart from them: the one a stale refusal named, into the index; and a fork with content and a copy into a missing epoch each read their source’s file once before writing, to refuse a change since the person looked as stale rather than copy over it. On a film on disk the folder watch then reads, once its folder is quiet, each .json path changed since it last settled, once (§6.1b): its cost, which the write-cost census (§12) counts apart from the act’s own reads, those keeping the ceilings above.

  • The record. ProjectController keeps one WriteRecord, and every writer it or its parts make — its own, a patch’s, an add’s, the session ledger’s and the pending sets’ — is given it. The writer enters each write and deletion before the source is asked (in flight), marks it landed when the source answers, drops it on a refusal or a failure, and keeps the path a stale refusal named — a refused write’s, or a fork’s or a copy’s check of its source — with its root, the project’s or the library’s. saveEntity hands back the file as written (its document and its text), which the caller holds without reading it again; a save that would write what the file already holds writes nothing.
  • The publication. One step at a time per controller, awaited by whoever asked it, never on a timer: a gesture’s re-derivation lists the folder — the listing decides which documents exist — serves the text of every document the index holds, and reads only a listed document that neither the index nor the record has once the folder has listed, and a file a stale refusal named; a full read (refresh, an open) reads every listed document once. Both end alike: once every read has answered, the record’s landed writes are laid over what was read or held, synchronously — a landed deletion drops its path though listed — the index is built from that text and published, and only then do those writes leave the record. A write still in flight is the next step’s. A re-derivation with nothing landed and nothing stale publishes nothing (project.publish.skipped, nothingLanded). A step whose listing fails takes nothing and answers IndexNotPublishedException to its gesture — the write landed, the view is behind, the person told with Re-read files; nothing is retried. A step that read the film being left, another source having opened, publishes nothing. Held text is decoded again, never shared, so no index’s document can be changed under another.
  • One view for every decision. What the record holds for a film is that film’s truth for every decision until it is published, and it is read in one place: ProjectController.view, the published index with the record’s landed writes of the film and its library laid over it — the overlay a publication makes (ProjectLoader.overlay), without the listing and reading nothing, built once per publication and landing and logged project.view (the film, the landed paths, ms). Every door that decides from the film reads it, never the published index — a plain fork copies the view’s version of what it was handed, while a fork with content stays checked against the version its content was built from, refused stale where it changed: the commands (update-entity, create-entity, move-entity, export), the pick, the star, Discard, a fork, an add, a move, the session review’s Pick and Discard, a new folder’s or scene’s short name, a patch’s baseline, a list’s cleanup, the pending set an action adds to or changes (PendingSets.pending: a medium already in the comp, the record a comp’s field change is for), and every writer the controller makes; the screen shows the published index, and a provider derived from it (the tree, the outline, pendingSetProvider) is what is on screen, never what decides a write. The expected text is the text the edit was built from: an edit built over the view — a label, a patch, a command’s change, a block moved into another scene — is written against the view’s text; a whole document built from a snapshot the person has been editing — the JSON editor’s, a form’s draft, a fork’s content — against that snapshot’s, so a write landed since, the app’s own included, refuses it as stale and offers the re-read, never put back (the JSON editor’s Re-read files makes the file as it is now its base, the typed text kept). A landed write leaves the record only once the publication that laid it over its index has published it; a publication that fails — its listing, its reads — leaves its landed writes in the record for the next, and the gesture’s failure (IndexNotPublishedException) carries what its writes answered, so a command says what landed: failed {stage: publish, stem}, written and not to be sent again. A guard also sees what is in flight: a short name is chosen against the view and every file of its type the record names, in flight or landed (ChildrenController.nextChildTag), so two creations at once take two; Discard is refused (DiscardOfficialException) while the record holds a write of the version’s official pointer naming it, the star refused as a changed file (StaleFileException) while it holds a write setting that version aside, and the assistant’s update forks a draft, never writing into it, while it holds a pick or a pointer naming that draft (inUseRecorded: the choice lands a moment later, and the draft is then the version in use). A direct patch starts from the view’s file, or from what its caller checked where no write of the app’s own has landed on it since, so the app’s own writes are never a stale refusal of the next. An edit of a children list starts from the list as the reader shows it in the view (withoutDanglingChildren), so a folder or scene the app has written and not published is never dropped as dangling. A re-read showing a draft’s own write — after a failed publication, Re-read files — gives the draft the times the writer stamped (the file’s and each block’s updated, each prompt’s created), so its next write carries none older. The record is per film: a publication step and an open take only their own film’s landed writes, its folder’s and its library’s, and only those, with its stale paths, are its news; a step that read a film another source has replaced takes none, so the open film’s save is published by that film’s own step, and a write of the film left that lands after another opened is nothing the open film publishes. Nothing is retried and nothing repaired: a change from outside the app is refused as ever.
  • The library once. The controller holds the library’s listing and text while it stays attached: every read of a film is handed them and reads the library not at all after the first; a development-mode save of a library entity is landed against the library’s root and laid over what is held, never over the project’s file of the same path.
  • The gesture. Every write door is one gesture: its writes, its receipts published after them (§7.4), then one publication, then what waited on it — the sync’s release of a patch and the next write of each commentary queue it entered. The doors: saveEntity, createEntity, fork, createAtEpoch, declareEpoch, createOutfit, pinPick, setPick, pickFromCheck, abandonPick, keepPick, selectProject, setOfficial (the pointer and the director’s pick: one publication), setDiscarded (its flag and pick; each parent list after it is a patch of its own), updateManifest, deleteTake, visit, the session ledger’s writes, the pending sets’ writes, patchEntity, and addChild (the new file, its pick and the parent’s list: one publication). removeChild is a patch; moveChild within one list a patch, and between two lists one gesture whose two patches borrow it (one publication); moveChildren one gesture whose patches — the destination’s, then each origin’s — borrow it, one publication of its own after its last write. One publication of its own, not an indivisible change: a publication of another act that lands between its writes — an autosave, or an origin’s open draft settled by its patch — may show the destination’s write a moment before the move’s own publication completes it, as it may for moveChild between two lists. Every write ends in a publication: a save through the write context a form’s saver kept from a patch, after that patch ended — the form’s Retry — is a gesture of its own, patch.retained, and publishes. noticeWritten takes the files something other than a recorded writer wrote and asks one publication with a listing for them, named as its gesture (sync.arrived for sync’s arrivals): each given with its text is entered in the record as landed, in its place after the writes already entered, and laid over the index unread; one given without its text is read again; with publish: false the texts are only entered, for a later call to publish; project.noticed {gesture, source, landed, read, texts, publish}. A gesture enters each commentary queue at most once. Folder-writing doors — createProject, importScript, copySessionTo — write a new folder and open it: the open is their one full read.
  • The patch’s three decisions, on the writer’s hand-back and the record, never on a publication, for a patch on its own and one inside an add alike: proof of the write — holds on the document the writer wrote; proof that none was needed — holds on the latest text the app landed for the file, else the view’s, adopting and releasing nothing (skipped: unchanged); permission to send while the form stays open (§6.14) — decided once the gesture has published: released only where the patch’s own text is still the latest the app has for the file, the record’s else the view’s; otherwise overtaken, and the typing waits for the form to be left.
  • A stale refusal is the one moment the index is known to be wrong: the refusal stands and is answered, the gesture’s publication reads that one file into the index, nothing is retried, and a dirty draft on that file adopts nothing — its next write is refused until the person discards (§9.5).
  • One publication for a pass. ProjectController.publishingOnce(name, body) runs a pass of many gestures — Discard all (SessionReview.discardAll) — while which every gesture of the controller, the pass’s and any other (an autosave), lands its writes in the record without publishing them (project.publish.held {gesture, held}); every decision inside reads view, which holds them, so a later item sees an earlier one’s flag, pick, list and outcome; at its end the film is published once, one re-derivation and one listing, whether or not the body failed, the body’s failure answered as it was, and nothing where the film was closed or replaced meanwhile (project.publish.skipped {reason: filmLeft}) (project.hold.start {gesture, depth, film}, project.hold.end {gesture, held, published, outcome, ms}). The screen takes the pass in one snapshot. What the accessibility bridge costs a rebuild is not changed by it.
  • Logged: gesture.start / gesture.end (the door, its writes, whether it published, ms); project.published (gesture, kind: rederive|reread, source, listed, held, read — the paths for a re-derivation, a count for a full read — decoded, landed — each path, deleted where so, the root where it is the library — stale, the index’s counts, list_ms, read_ms, index_ms, reads_at_once, ms); project.publish.skipped with its reason; project.publish.failed with its stage, type, message and ms; entity.patch.decided (the proof, whether it held, the release’s decision); project.landed.dropped for a write handed to a read of another root, which the controller’s per-film take never does; project.view (the film, the landed paths, the entities, ms); version.fork.source where a fork copies the view’s version rather than its caller’s; version.discard.refused (pointer: recorded | index) and official.refused (discarding | deleting, whether the write landed) for a guard that saw the record; the writer’s writer.refused / writer.failed carry what a creation or a fork tried to write — the name, the tag, the document. project.loaded, the store’s debug line, is a full read’s only.

8.1 Model — filmopen_state/tree_node.dart, filmopen_state/selection.dart

Section titled “8.1 Model — filmopen_state/tree_node.dart, filmopen_state/selection.dart”

TreeNode: id (stable, path-like), label, detail (muted secondary text), tooltip, icon (a TreeIcon: an entity type, or a glyph for a grouping row — the model names the thing, and filmopen_tree/tree_icons.dart chooses the Flutter icon, so the model imports no Flutter), selection (what clicking shows; null for pure groupings), children, initiallyExpanded, isOfficial (star), isPick (the viewer’s pick, a green check), isStalePick (the project made another version official after the viewer picked this one, §13.2: the label and the check are drawn in the error colour, with a tooltip), hasProblem (warning icon, error-coloured).

Selection is sealed: ProjectOverviewSelection, CategorySelection(type), EntitySelection(type, tag, {epoch, stem}), BatchSelection(stem) (a render batch, or a record of a pending set’s history), PendingSelection(type) (the viewer’s pending comp, assistant session or render queue, or the empty page saying what fills it — no file is made to give it a stem; match key pending:<type>, {"kind": "pending", "type": "cp"}), OtherFilesSelection. Each exposes matchKeys, most specific first (entity:<stem>, entity:<type>:<tag>@<epoch>, entity:<type>:<tag>), which TreeModel indexes as the navigation anchor. The browser resolves the displayed stem separately and moves that anchor to its surviving author row where several handles have versions, retaining an older requested version on the page.

Selection is a sealed class, switched over exhaustively in three places — the detail pane, selectionToJson and the agent host’s target check — and ScriptSelection ({"kind": "script"}, match key script) is what the tree’s Script row carries: the row has a page of its own (§9.3). What a row of the script says is ScriptRowText.of(l10n, row) (script_row_text.dart): its label, its detail, its tooltip, the short name a filter finds it by, the page it opens and whether a reader flags it, from the walk’s ScriptRow — the tree builder draws it, and the Script page and a folder’s contents read the same function, so the three cannot come to say different things; folderKindDetail is the folder’s kind in words.

A row of the Script branch also carries its ScriptPlace (TreeNode.place): the file whose children lists it (parent: the project file whose list is the script, or the folder version whose list is drawn above it — the viewer’s assistant draft where there is one (§5.12); null for the Script row and for a row under Not in the script that no list holds), the entry as that file writes it with its occurrence among equal entries — what the list’s edits take — the file an Add there goes into (container: the project file whose list the script is for the Script row, the version whose list is drawn beneath for a folder’s row — ScriptOutline.root and ScriptRow.contents, the viewer’s assistant draft where one exists, so that what is added is seen at once (§5.12); a folder page’s Contents card takes the same file from the walk (contentsOf answers the rows and their container), so that its Add writes, and is checked against, the list it shows — a pinned folder’s pinned list included — and the new-entity dialog writes exactly the file it is given; null for a scene, and for a second listing and a cycle, whose contents are under the first), and the name a message calls it. ScriptPlace.ofRow(row) makes it, for the tree and for a page’s row alike; it also says where the entry stands in its list (index, siblings — every entry of a list is a row, readable or not, so a row’s place is the entry’s), and carries the entry of a row no list holds, which Move to… files under that name. The model says where things are; what a row’s menu offers is the pane’s to say (§9.2). TreeNode.shortName is the entity’s short name where the row shows it nowhere — a folder’s, whose detail is its kind — so that the filter still finds a row by name or tag.

The marks (tree_marks.dart): the rows a person has marked for one move that takes more than one, apart from the one row open in the detail pane. TreeMarks holds the marked node ids (count, isMany, holds) and the anchor a shift-click measures its range from; treeMarksProvider‘s notifier is their one door — only, toggle, range and clear(reason) — and logs every change as tree.marks {action: only|toggle|range|clear|reset|kept, surface: tree|contents, requested, before, after, anchor, excluded: {id: reason}, reason, ms}. The caller says which surface acted and, for each row it refuses, why (noPlace, script, noEntry, unreadable, inspectionOnly, locked: the pane’s own rule for what can be dragged, made to answer why); the notifier stores neither and consults no widget. Only a row that can move is marked, on every path; any row clicked may be the anchor. Another source resets the marks (reset, sourceChanged); a re-read of the same one keeps them. A move that consumed them clears them as it settles only while they are still the marks it took (consumed): a marking made meanwhile is kept (kept, newerMarks). The ids are the tree’s, which the Script page’s Contents names its rows by too: scriptRowIds(parentId, rows) is the one id rule of a list’s rows — the tree builder’s and the Contents’ — and TreeModel.nodesInOrder(ids) answers the marked rows’ nodes in the order the tree draws them, in one walk, so a selection is acted on in the script’s order, not the order it was clicked in.

8.2 Builder — filmopen_state/tree_builder.dart

Section titled “8.2 Builder — filmopen_state/tree_builder.dart”

Top-level rows, in order:

  1. Project — name, tag, its authors as children only where there are several handles, with the official star on the author holding the official version; flagged when a choice is pending.
  2. Chats — present where the film holds a chat’s record: every as record, any author’s, the viewer’s open one included, newest first (SetRecord.newestFirst), each named by what it was asked (chatOrigin, the origin its page’s line reads): the action’s label through the record’s own assistant’s catalog, else its id, and · the entity’s name, else its id, Write the Synopsis · Grail Check — a record that names no action by the minute it began on this device’s clock —, the minute in its detail after archived (a tree drawn before the catalog is read names the action by its id until it is drawn again), and archived · n changes where its chat is not live in this app (§7.4) — a chat closed, from an earlier run or another device —, n changes where it is, another author’s handle after; each opens its record’s page (BatchSelection), where a held chat is shown (§6.11a). The viewer’s pending session is still reached as PendingSelection(as), the MCP’s route, with no row of its own.
  3. Script — the row opens the Script page (ScriptSelection, §9.3). Beneath it, the script as the viewer sees it, drawn from the one walk, scriptOutline (§5.12), which the builder makes once — as workingScriptOutline, whose lists are the viewer’s assistant draft’s where one exists while every row names the version in use, logged as tree.outline.start and tree.outline.done {caller, viewer, root, base: round|inUse, children, folders: [{tag, drawn, list, base, pinned, children}], unfiled, dropped: [{parent, stem, why}], duplicates: [{stem, in}], ms} only when the outline differs from the last one logged, so that a rebuild which changes nothing logs nothing —: the project file’s children in its order, each folder’s beneath it, to any depth; the Script row counts them as items, entries that name nothing included, where 1.11 counted roots. Every row of the branch names its entity first, as the library’s rows do: a folder row is the folder’s name with its kind in words as the detail (Labels.folderKindLabel: the vocabulary’s words in the reader’s language and in lower case, as every detail of the tree is; another token as written, capitals and all, since it is the author’s word; nothing where the folder has no kind), the folder icon, and is open at first; a scene row is its name with its short name as the detail; a shot and a cue row likewise. The stem is the row’s tooltip (§8.3), since the row no longer shows the short name of a folder at all. Beneath a folder: its author rows where it has several authors, its Cues row where a cue belongs to it, then what it holds. Beneath a scene: its author rows, Shots, Cues. A Shots row lists the scene’s explicit shots or, absent that, every <scene>.<n> group in natural order; a Cues row the scene’s explicit cues or, absent that — and always for a folder — the cues that belong to it (cueGroupsByOwner, §5.12, found once per build: a cue that names a folder is never a scene’s); the tooltip says which, and a folder’s says that its cues are the ones that name it, since a folder lists none. An explicit list that names one shot or cue twice gives the second row #2, as children does. A row’s id is its parent’s id, /, the entry’s type and what the entry writes after it (script/fo:f1/sc:1; entry:<index> for an entry that names no type), and since that is whatever a file wrote, the signs an id is built with are written as their codes in it — %25, %2F, %23 for %, /, # (scriptRowIdText, the one rule the Contents card’s row identifiers read too): an entry fo_f1/sc:1 would otherwise claim the id of scene 1 inside folder f1, and a drop or a move looked up by id (TreeModel.nodeOf) would land elsewhere. A short name and a stem hold none of the three, so every ordinary id reads as it always did. Empty wrappers are omitted; folders, scenes and warning rows never are. Several authors have flat rows directly beneath the folder or scene (the same rule as under an epoch), so another author’s scene is one click away although the row shows the version in use; a Compare column heading opens its version as well. What a reader reports is a flagged row with its reason, never a guess (the format’s §8.5): an entry that names neither a folder nor a scene is shown as written with its ChildProblem’s sentence (a bare tag, another type); an entry that names nothing, or whose version cannot be chosen, is shown as written — never blank — with the resolution’s reason, and where the project has the folder or scene its page and its author rows are still reached from it; a second listing is the entity’s name with Listed a second time in the script, and a folder that lists one above it is a circular story reference row; neither is followed. A row’s id is its parent’s and the entry — script/fo:s1/fo:e2/sc:5, a pinned entry by its full stem (script/sc:sc_1_ann_v1), an unread entry by its place (script/entry:3) — and a second equal entry under one parent gets #2, since an id is unique; a shot’s and a cue’s id hangs on its owner’s (…/sc:5/sh:5.2), and the wrappers are …/shots and …/cues. 2a. Not in the script (unfiled) — only when something is: every folder and scene of the project that no list names (ScriptOutline.unfiled), flagged, each with the rows a folder or a scene has beneath it (unfiled/fo:f6/sc:6), so that a file taken out of a folder, or one whose add did not finish, never disappears. A project made before folders shows its scenes here and an empty script.
  4. Characters, Locations, Props, Styles, Misc, Documents — each entity by tag, then its epochs in the project’s declared order (§8.2), each row named by the epoch’s label with its year as the detail and never its token — an unlabelled declaration by its year, else its token (epochNaming, and epochLine for one line, the year beside the label, in the epoch chips and the new-entity dialog’s pull-down), the row’s id unchanged — then, only where several handles have versions, one flat row per author, alphabetically by handle, showing that author’s latest retained version, or latest discarded version where none is retained (author vN, with the version label or from <stem> as detail; official star; the version in use — the pick, pickAt — as a green check. Only an explicit pick wears it: official in use with nothing picked carries the star alone, and official comes before an author’s own latest (§6.1)). An author’s row has no children. The star follows the author holding the retained official version, and the check and stale colour follow the author holding the pick, even when the row shows a newer version. A sole author has no row beneath the parent; every version, including discarded history, is reached through the displayed author’s version pull-down. An epoch (or an entity without epochs) where nothing is in use — several authors, nothing official, no pick — is flagged with the warning icon and says so in its tooltip. Dotted tags nest under their parent: areas under a location as Areas, and a character’s outfits under the epoch they are at — character → epoch → outfit → author and version. A character has one epoch row for every epoch it or one of its outfits is at, the declared ones first; each holds the character’s version rows there and then that epoch’s outfits, each named by its version in use there — where none is, by its id, never by another epoch’s name — with its own author and version rows and opening the outfit at exactly that epoch; an outfit is never listed at another epoch. An epoch row where only outfits are carries no character version and nothing from another epoch, and opens the character whole. One whose parent file is missing — and an outfit whose tag has no dot, which names no character — is listed at the top level with a warning (No parent character for …); an outfit whose id after the dot is not the <owner>-<n> FilmOpen allocates (a hand-named main-hero.oilskin), and every outfit whose tag has no dot, is flagged too and stays readable, its tooltip saying so, both sentences where both hold. The same version rule applies to the project’s own versions and to models and platforms; a plug-in’s documents are the exception below. An entity row is named by the version in use (FilmProject.nameOf: the pick at the project’s default epoch, else at the entity’s first epoch), and so are the epoch overview’s heading and the category lists, so a rename in the version being used shows everywhere the entity is named as a whole.
  5. Models, Platforms, Plugins — entity, then versions; a plug-in is one row per tag and nothing beneath it, with no version, pick or stale mark, named after its highest document, the stock author’s first. One installed version of each plug-in runs whatever the film (§6.11), and Settings → Plug-ins names it; the row browses the film’s documents and makes no claim to be the one that runs.
  6. Render batches — first Pending, the viewer’s render queue (PendingSelection(rd): its item count, or an empty row opening the empty page), with incomplete queue history beneath it; then Comps, its first row Pending comp always (PendingSelection(cp)) and every other comp newest first by created, each title · date (Untitled comp where it has no title); then the render batches, r<id> with author, kind and item count, then the complete queues of the day, newest first by created, each by its date and item count. The Batches count counts the render batches alone. A record of the history opens with BatchSelection(stem) and names its author where it is not the viewer’s. One pending record per author per type is the app’s rule: where a folder holds two, the newest by created is the pending one and the other is in the history (Project Specification §12.7). A complete queue has at least one item and a take or error on every item (§6.15); its row id and selection stay the same when it moves, including a pending record completed by removal after a partial pass.
  7. Other files — present when the loader reports files it does not understand.

A pick an official version overtook (Pick.staleSince) is drawn in red on the version row that carries the check and on every row above it up to the entity — the author’s latest row even when the pick is older, the epoch row, the library or spec entity row (never a plug-in’s, which shows no pick), a folder’s or a scene’s row, a shot or cue row (which has no version rows of its own), the Project row — so a collapsed tree still shows that a page has something to ask. An outfit is an entity of its own under its character’s epoch row: its chain ends at the outfit’s row, and the character’s epoch and entity rows are not painted for it. A folder’s or a scene’s row that shows a version pinned by a full reference is not the pick and is not painted, although its picked author’s row still is; the Shots and Cues wrappers, like the category rows, sit above the entity and are not painted. Nothing about resolution changes: the pick is still what the row shows (§7).

The recycle bin (Project Specification §7) keeps its rows: a row whose version is deleted (TreeNode.isDeleted) is struck through with deleted as its detail — a script row whose drawn version is (ScriptRowText.isDeleted, ScriptRow.isDeleted), keeping its place in the script; a library entity’s row where its version in use is deleted at every epoch (isGroupDeleted); an outfit’s row where the version in use there is; an epoch’s row likewise, deleted after its year (<year> · deleted); and a version row whose own file is. Every such row opens the version it shows — a script row its pinned or resolved stem, an entity’s own row deleted at every epoch its first epoch’s version in use rather than the epoch overview — whose page says it is deleted and offers Restore (§9.3); every epoch keeps its own row.

A discarded version (Project Specification §7) stays in its author’s version pull-down. An author with only discarded versions retains a row where several handles have versions, with Discarded as its detail; there is no Discarded fold. The star is never drawn on a discarded version (a pointer naming one counts as none, §6.1), and a pick of the viewer’s naming a discarded version lights no check: it is ignored. An entity whose every version is discarded is shown nowhere (EntityGroup.hasRetained, FilmProject.retainedGroupsOf): no row in the library, the pipeline, a location’s Areas, a character’s outfits, a scene’s implicit Shots or Not in the script; no row on the category page; no choice in a scene’s character and location pull-downs, Located within, a prompt’s mentions or the Script page’s filters; and every count leaves it out. Its files stay on disk, marked, and keep their numbers (groupsOf and allocation read every group); an exact stem still opens its version, so the epoch overview, reached that way, names it by its most recently updated version’s name, else its tag (groupName). A live area or outfit whose parent is wholly discarded is listed at the top level, flagged, as one without a parent file. An entry of a list, a shot’s or a cue’s reference that names one is drawn as the reader already draws a reference that names nothing retained. Nothing brings it back on screen; it is in the files. The tree logs what it left out: tree.hidden {reason: every_version_discarded, count, keys, viewer, ms}.

Every visible string comes from the AppLocalizations the builder is given.

8.3 Widget — filmopen_tree’s tree view

Section titled “8.3 Widget — filmopen_tree’s tree view”

Five files (the tidy after folders): tree_view.dart the list, its filter, the edge scroll and how long a drag rests on a closed folder before it opens (TreeView.openOnRest); tree_row.dart the row — what it draws, the drop it takes and the drag it starts; tree_drag.dart the drag’s doors (TreeDragDrop, DropPlace) and the drop’s geometry as a pure function, placeIn(share, places); tree_drag_widgets.dart what follows the pointer and the two device-restricted draggables; tree_icons.dart the one place that chooses a Flutter icon for a TreeIcon. Nothing of the view is exported beyond what always was.

A flat, virtualised ListView over the visible rows. A row shows indent, chevron (toggles), icon, label plus muted detail as one rich text, then the star, the check or the warning icon. A stale pick paints the label and the check in the scheme’s error colour and puts the reason in a tooltip on both. A row in the recycle bin (isDeleted) strikes its label through. Any other row that has a tooltip says it on hover of its label (before it the view drew a row’s tooltip on a flagged row’s warning alone, so a wrapper’s order from the list and a library row’s from the shared library were never seen); a flagged row keeps saying its own on the warning, and the theme’s 600 ms wait keeps a pointer moving down the tree quiet. Tap selects when the row has a selection, otherwise toggles. There is deliberately no double-tap handler: it would delay every single tap by the double-tap timeout. With a filter, only rows that match or have a matching descendant are shown, fully expanded. A row’s menu: the view takes menuOf(node), a list of TreeMenuItems (identifier, label, icon, what it does, and the reason it is disabled, shown as its tooltip), and asks for it when the menu is opened and not while the row is drawn: a tree draws every visible row on every frame, and what a row offers is walked out of the project. A row that is a place of the script gets a ⋯ button (tree:<node id>.menu) — on the row the pointer is over, on the selected row, on its parent where the selected row is an author, and on a row that cannot be selected, a flagged entry that names nothing, since a touch has no other way to it (a long press is a drag’s) — and opens the same menu on a right click, where it was asked for. A place of the script is the same rule as a row with something to offer, since every place offers at least one item: a list to add into, an entry to move, or the list that holds it to be taken out of. The Script row is a place only where a project file is in use — with two authors’ project files and none official the reader asks which first (§6.1), and until it is answered the script has no list to add into, so the row offers nothing and wears nothing. A menu that answers nothing all the same opens nothing, rather than reaching Flutter’s own assertion. The button and the menu are row_menu.dart’s (RowMenuButton, showRowMenu, TreeMenuItem), which a page’s rows use too, so a row is operated the same way wherever it is drawn. A row is 30 px with or without the button; it is built only while it shows, so it takes no tap it cannot be seen to take.

Marking (§8.1). A plain click opens a row and marks it alone; shift marks every row drawn between the anchor and the row clicked, and control or command adds a row or takes it away — neither opens anything, since a range is chosen to act on, not to read; a shift-click with no anchor drawn (folded away, filtered out, none yet) is taken as the keys’ next meaning and logged (tree.marks.rangeFallback {anchor, clicked, reason: noAnchor|anchorNotDrawn}). Every marked row is tinted, whatever their count, in secondaryContainer at 45 %, more lightly than the open row, which keeps its own. A drag of a marked row while more are marked carries them all, and what follows the pointer then says how many — N items — counting the marks that still name a row. The keys are read from HardwareKeyboard; an agent, which taps through the semantics tree, marks one row at a time.

Dragging (§3.11). The view takes a TreeDragDrop — canDrag(node), placesOn(target), refusal(dragged, target, place), onDrop(dragged, target, place) — and knows nothing of the script: what may be picked up, what a row takes and what a drop does is the pane’s to say (§9.2). A row that may be dragged is one with the mouse at once — as the pointer moves past the slop, so a click stays a click — and with a finger or a pen after a long press, a tap and a scroll staying what they are: two recognizers, each taking its own kind of pointer (_MouseDraggable — the mouse alone: a trackpad’s click-and-drag arrives as a mouse, and its two-finger pan is the list’s scroll, _TouchDraggable), nest around the row. Since a long press is then the drag’s, the label’s tooltip on such a row answers to the mouse’s hover alone (TooltipTriggerMode.manual): it sits deeper in the tree and would otherwise win the same press. What follows the pointer is the row’s icon and name; the row left behind fades. Every row is a target while a drag is on, and the pointer’s height on it says the place (DropPlace): a row that takes a drop into it and beside it gives its upper and lower thirds to before and after and its middle third to into — 10 px each of a 30 px row, since a finger hides the row it is on and a quarter was 7 px; one that takes only the edges splits in half; one that is no part of the script has no place. A place the drop may go shows: the row’s ground turns primaryContainer for into, a 2 px line in primary at the row’s upper or lower edge, from the label’s indent, for before and after. A place it may not go shows nothing, and a drop there moves nothing and says the reason (showMessage) — a target therefore accepts every drag but its own row’s, which is a drag given up and says nothing, and hears nothing of it while it is over itself. The pane is asked once for a place — the answer walks the script — and not again while the pointer moves within it. A closed folder the drag rests on into for TreeView.openOnRest (700 ms) opens, so that what it holds can be reached without letting go: the time is counted from where the pointer last stopped (6 px of wander allowed, a held finger never being still), so a drag sweeping across the row opens nothing. The rest logs where it starts and ends, never per move: tree.drag.rest.started (the folder’s row, its label and the row dragged), then tree.drag.rest.opened, or tree.drag.rest.ended with its reason — left, dropped, edge (the pointer moved to the row’s upper or lower third, beside the folder), refused (to a place the drop may not go), expanded, no_children, disposed, unmounted, not_over —, each with the time since the rest started and how often the count began again. Within TreeView.scrollEdge (40 px) of the list’s top or bottom the list scrolls that way, 8 px a frame — one timer for the length of the stay, reading the direction at each tick, so a pointer sliding along the edge scrolls as one that holds still does — until the drag leaves the edge or ends. It logs tree.drag.scroll.started (the edge and the reason, near_top or near_bottom, the pointer’s height in the list, the list’s height, the 40 px and the scroll offset), tree.drag.scroll.turned when the drag goes to the other edge, and tree.drag.scroll.stopped with its reason — away_from_edge, pointer_up, pointer_cancel, drag_ended, disposed, unmounted, no_list —, each with the offset and the time since it started; never a line per tick. The end of a drag is heard however it ends: by the row that started it (onDragEnd, onDragCompleted, onDraggableCanceled — the second being the only one Flutter calls once that row has been scrolled out of being, which the edge scrolling does) and by the list itself when the pointer comes up (Listener), since a timer left running would hold the list at its end for good. A drag cannot be made by an agent nor replayed by the recorder (§14): every move a drag makes is a menu item too.

The sharing preferences are machine-local: Settings.sharingFolders is the JSON-object preference sharingFolders, keyed by film key, with sharingFolderFor and setSharingFolder (null removes one entry). driveSignedIn, changed by setDriveSignedIn, is the Google sign-in hint, independent of accountSignedIn; drawing it never reads the credential store. ProjectController.updateManifest delegates to the writer with the current viewer and guards, one gesture, whose one publication follows (§7.5).

ProjectController.importScript settles viewer changes, then the film’s queued writes and open drafts as any open of another source does, a refusal answered as the import’s drafts_unsaved; it permits one import at a time, converts the path through filmopen_fdx, whichever screenplay format it holds, persists through the store and opens only after the new source has loaded completely. Failure leaves the current project and selection intact. Success opens and remembers the new folder as New project does. ProjectImportException carries a localization code. The import logs project.scriptImportStarted {name, path, parent} first, before its one-at-a-time refusal and the settling; project.scriptImportStage {stage: settle|read|write|open, ms} at each stage; project.scriptImported {format, scenes, characters, locations, warnings, ms, folder}; project.scriptImportRefused {code, reason, stage, ms} as a warning, the refusal of a second import included; and an unexpected failure as the error project.scriptImportFailed {stage, error, cause, stack, ms}, cause being what the read-back of a written folder answered. Imported outfits without an identified owner are shown in the character category alongside other outfits instead of disappearing from the tree.

An unassigned outfit has a type-qualified tree identity, so its tag may also belong to a character without colliding with that character’s row. Imported or not, it carries the two warnings of any undotted outfit, its missing parent and its legacy id, until it is given a character. A screenplay import opens its folder without the normal detailed project-open log, the import’s own events saying what the read-back answered.