The account, the device and provider keys
The filmopen.ai account, the build’s stamp, the device’s key, and the keys a person gives the application for the AI platforms.
6.6 The filmopen.ai account — filmopen_state/account_names.dart, filmopen_account/account_auth.dart, account_registration*.dart, auth_callbacks.dart, account_model_provider.dart, account_profile_button.dart, account_popup.dart, account_sign_in_dialog.dart, account_card_model.dart
Section titled “6.6 The filmopen.ai account — filmopen_state/account_names.dart, filmopen_account/account_auth.dart, account_registration*.dart, auth_callbacks.dart, account_model_provider.dart, account_profile_button.dart, account_popup.dart, account_sign_in_dialog.dart, account_card_model.dart”The same account as on filmopen.ai, offered by the rail’s profile beneath Help (§9.1) and its popup; local projects never need it. FilmOpenAccountAuth (a singleton) initialises the Supabase client lazily with PKCE, the secure-storage adapters of §6.4 and, on the web, a callback predicate that accepts exactly the page’s own URL with a code or an error; off the web the library’s observer is given a predicate that accepts nothing, since every link comes through the app’s own door and the model exchanges it itself (below). It initialises only when asked: on the first sign-in gesture, on the person’s Send of a recording, which asks for the session’s token (§6.8), or at start when the accountSignedIn hint is set or the app was started with a callback URL — on Windows the command line (launchArgumentsProvider), on Android the intent, both read through filmopen_platform‘s IncomingLinks door (incomingLinksProvider), the one door for links on every platform. The account’s state is one for the app (accountModelProvider, account_model_provider.dart): made as the rail’s profile is first built, at start, and kept as long as the application, its subscription to the link door with it, so that a sign-in’s result that comes back while the account’s popup is closed is still exchanged; the popup and the sign-in dialog only show it. Nothing touches the plugin channels otherwise — which is what keeps every widget test and a fresh install free of it — with one exception: the state asks the link door as it is made (app_links’ initial link and its stream), since a launch by the callback has to be honoured without a gesture; a test’s door is the unavailable one and touches nothing. The links do not go away — the door answers the launch link from a cache for the life of the process and Windows keeps its command line just as long — and a sign-in result may be exchanged once, its code being spent by the first exchange, so which callbacks a process has acted on is kept outside the model in AuthCallbacks (auth_callbacks.dart): claim decides whether a link is acted on, whoever was handed it and however often, and the state asks isSpent before it treats a link as a launch to wait for. A model of its own holds no such memory. A test hands the state a stand-in client and a ledger of its own (accountConnectProvider, authCallbacksProvider). A failed initialisation disposes the half-built client so a retry starts clean.
The project. The app signs in to the Supabase project the build defines FILMOPEN_AUTH_URL and FILMOPEN_AUTH_PUBLISHABLE_KEY name — the publishable key alone, public client configuration — and without them to the website’s development project, the one its development Worker verifies a token against: every build is a development build for now, and a release build will name its own project through the two defines.
The service returns codes, not sentences: AccountAuthProblem (codeInvalid, rateLimited, identityTaken, storageUnavailable, browserUnavailable, callbackUnavailable, sessionExpired, failed, unavailable), rendered by Labels.accountProblemText; the raw AuthException.message, which echoes the callback URL’s error_description, is not shown; it is logged with the error’s type and codes (accountErrorData), as are a PostgrestException‘s message, details and hint and the Supabase libraries’ own warnings (account.libraryWarning, with their words). Every step is logged with dotted events (account.initialized, account.initFailed, account.signIn {method, email?}, account.codeSent {email}, account.signedIn {userId}, account.signedOut, account.refused {op, reason, email?} where an input is refused before anything is asked, or a pair the server refused, account.failed {op, problem, type, code, status, error, ms}, account.protocolRegistered, …), and each gesture ends account.finished {op, outcome: done | refused, ms} or account.failed with its time — the person’s address logged as it came, never a token, a code or a password.
The profile, the popup and the dialog. The state — client, user, the step in progress, the last failure — is AccountCardModel, a ChangeNotifier the rail’s profile, the popup and the sign-in dialog show. The account’s public snapshot. AccountSnapshotAdapter (filmopen_account/account_snapshot_adapter.dart) publishes the state as the app’s AccountSnapshots (§7): made and installed with the state (accountModelProvider), detached with it — a detached adapter publishes nothing again, and gives the default back only where it is still the source installed, never replacing a newer one. It observes the state and never connects, restores or signs in; its one request is the balance’s. Loading is a returning session (the preferences’ accountSignedIn hint as the state was made) still awaiting its client, not failed, or a browser sign-in’s result still to come (oauthPending); authenticated is a known user, even while a step runs; anything else is signed out — a restoration that found no session or failed (the hint is never authentication), or a start with no hint and no user. A change of state, of name or of balance is published; nothing else is. The balance is read from the website at every sign-in (and every launch that restores the session) and again as the popup opens, after a funded call’s settled receipt, after an operation’s end answered 200 and as a plug-in’s call on credit closes (ProviderFunding.balanceMoved, once per call whatever its operations, after its last request rather than after each completion inside it), and a lent render’s charge or earning (lending: the supplier’s completion the website took, the buyer’s handling of a published result once it is settled, §6.11, Lending; each logged account.balance.refresh {reason: receipt | end | call-closed | lending, signedIn}, the call’s as usage.balanceMoved too), with no timer and no retry, so the rail shows what the website says at that moment (AccountBalanceReader, filmopen_account/account_balance.dart): GET https://<kFilmOpenDomain>/v1/balance with the session’s access token as the bearer, refreshed first where it is past its hour; nothing is asked while nobody is signed in. The answer is read strictly — unit is usd_cent, every amount an integer, available not negative — and available (what can be spent now) is the amount shown, in US cents; any other unit is malformed. The read logs both amounts on success and an answer it could not read whole. A refusal, a malformed answer (a page that is not JSON among them) or a failed request is unavailable, logged as account.balance.failed with its status and code — a refusal’s error where it has a code’s shape and its says as it came, malformed with the answer whole, or the failure’s type and message — never the token. Unknown while the first reading runs; a later reading keeps the last known amount until it answers; another person, or nobody, keeps nothing of the last one’s. The ledger is read for Activity’s Usage alone (AccountLedgerReader, filmopen_account/account_ledger.dart, installed through filmopen_state’s AccountLedger seam, off the web; §6.7): GET https://<kFilmOpenDomain>/v1/ledger with the bearer alone, a page of at most 200 records at a time, newest first, next followed as before for at most five pages — a fifth page that still names a next leaves the reading incomplete, and the card says only the newest records are counted — with month where a month is shown. Each record’s id, instant, kind, signed amount and unit are read, and the job’s id of its top-level lending object where it has one; a funded call’s settlement carries no such object, and what a caller attached under detail is never read as one. The answer is read strictly — a record not of the contract’s shape, a unit other than usd_cent, an amount that is not an integer, a lending object without a job’s id, a next neither an integer nor null, an instant that does not parse, a month not of the route’s pattern or a page that is not JSON makes the whole read unreadable as malformed — and a refusal is unreadable by its code, a failed request as unreachable, each logged as account.ledger.failed with its status, code and the website’s says, the answer as it came where it is malformed, or the failure’s type and message. The read logs its start and its end (account.ledger.read {outcome: start | complete | incomplete | signedOut, month?, reason?, rows?, months?, says?}) and each page before its bearer is added and as it answered (account.ledger.page {page, month?, before?, limit | status, records, next?, rows}, rows each record read — {id, at, kind, cents, job?}), each with the milliseconds since the start; never the token. No token sends nothing, and a 401 reads as signed out. Nothing of the ledger is kept on the device: it is read again as the section is shown, as its month changes, on UsageSection.refresh and when the sign-in changes — never on a publish of the balance alone, which follows every funded call’s receipt — and never polled. The rail’s profile (AccountProfileButton, nav.profile, beneath Help) reads that snapshot (accountSnapshotProvider), the default source — before the state is made — reading as signed out: a profile icon, a name and the account’s state under it. Signed out, the username in effect (§9.4: default where none is chosen) above Login and Signup, one action, the popup; loading, Loading…; a source that cannot say, Account unknown; signed in, the filled icon, the username in effect and the balance as its state says it: a known amount written in dollars from its integer cents (accountBalanceAmount), by splitting digits, never through a double, zero written as zero; on one line that shrinks to the rail rather than break inside the number; Balance unknown; and no line where none is reported, since a line there would read as no credit — never an amount nobody reported. The rail is 64 px wide and stays so: the name is cut with an ellipsis, and the whole — Username "<name>" · <the state as a sentence>, a known balance as Balance: $0.00 — is the control’s tooltip and its semantics label: one button to the semantics, focusable as the rail’s other buttons are, its drawn lines left out so that nothing is said twice. The support flow reads whether a person is signed in from the state itself (signedInGiven: the hint until the state knows better). A press opens the account’s popup (AccountPopup): signed out, one button, Log in to filmopen.ai, with the logo (plus Cancel browser sign-in while one is pending; held while a browser sign-in waits, its tooltip saying to finish it in the browser), under Signing in for the first time creates your account., since the rail says Login and Signup and one button does both; it names no provider, so a “GitHub” there cannot be read as a GitHub sign-in beside the one that will store projects; Close (account.close). The one opener: anything that asks a person to sign in — the support flow’s Sign in (§6.8) — calls AccountOpener.open (accountOpenerProvider, filmopen_state), which lands on Settings and asks the profile to open the popup, with a reason where the caller has one — the support flow’s says what became of its recording — which the popup says at its top, where a message at the window’s foot would stand behind it; one popup at a time. The button opens AccountSignInDialog, laid out like any single-sign-on prompt: the logo, Sign in to filmopen.ai, one full-width Continue with … button per provider (Google, Discord, GitHub) with its brand mark (filmopen_account/provider_mark.dart: the Google “G” in its colours, Discord in blurple, the GitHub mark in the theme’s ink), an or rule, then the e-mail address and Send a sign-in code, which turns into the code box. Under the address an optional, obscured Password box and Sign in with the password sign in without a code (signInWithPassword, logged account.signIn {method: password, email}; the password is an argument of that one call and never enters a log line); a refused pair (invalid_credentials) is that road’s own sentence, never the wrong code’s, logged account.refused {reason: invalid-credentials, email, code, status}. Where the gesture’s first connection restores a stored session (account.signedIn {event: restored}), either road stops there — no code is e-mailed and no sign-in posted — and the dialog closes on the change, as for any sign-in. The boxes and the three buttons under them carry the identifiers account.email, account.password, account.send-code, account.sign-in-password, account.code and account.sign-in. The dialog closes itself, once, when the browser takes over or the user is in — the state tells it more than once as a step ends, and a second close, or one after Escape or a press on the scrim closed it, would close the popup beneath; Cancel drops its validation text but keeps a code already sent, so reopening resumes at the code box. Signed in, the popup shows the logo, who is signed in, the balance as the snapshot says it and as the rail’s tooltip says it (account.credit: Balance: … where it is known, Your balance is not known yet., or Your balance could not be read just now.), drawn again as the snapshot changes, the Username pull-down of the account’s names (below; no claim entry — Settings has it), Sign out here (account.sign-out; this device only) and links to the website.
The account’s usernames (filmopen_state/account_names.dart, accountNamesProvider). Signed in, the username is one of the account’s, chosen from a pull-down, never typed. The names are read once per signed-in account with AccountProjects.identities() (the primary first), keyed by the account’s user id (AccountSnapshot.accountId, filled by the adapter, compared by its equality, never serialized or printed) and again on an explicit refresh (Retry, a claim). A sign-out or another account clears them, an answer for the one before is dropped (stale), a change of the username still waiting on the drafts under the one that left is superseded before it can commit (settings.viewerSuperseded {reason}), and the popup forgets that account’s refusal (account.popup.cleared {reason, refused}). States: signedOut, reading, read, failed (a refusal’s code, or failed). The rail keeps them alive, so this runs with no page open. Signing in sets the username: after each read, where the username in effect’s owner is not one of the names, the primary becomes the username through setViewerHandle (the open drafts written first); one of them (a workspace kept) is left; none — an account with no name yet — leaves it, and Settings and the new-project director offer the primary’s claim. A failed read changes nothing; nothing retries and no username is generated. Signing out changes nothing: the last username stays. Every choice — the sign-in rule, Settings’ and the popup’s pull-downs — goes through AccountNamesController.choose and reads its ViewerChange: a draft that cannot be written leaves the username, shown as it is, the refusal said with Try again; a superseded choice says nothing, the later one owning the answer. While the names are read, when they cannot be, or when there are none, the username in effect is shown read-only; it may then not be one of the account’s. Logged, the names included: account.names.start {reason: signIn | retry | claim} (with outcome: signedOut where nobody is signed in), account.names {outcome: read | refused | failed, reason, count and names | code | type and error, ms} or {outcome: stale, ms}, account.names.cleared {reason: signedOut | anotherAccount}, account.names.viewer.start {source, reason, username?}, account.names.viewer {source: signIn | retry | claim | settings | popup, outcome: set | kept | none | refused | superseded | invalid, reason, username?, refusal?, ms} (the name chosen, or the owner kept), and the presentation shown, settings.username {mode} and account.popup.username {mode}. One-shot confirmations go through showMessage while the popup is closed, and are said in the popup, under its buttons, while it is open, since a message at the window’s foot would stand behind its scrim; the current step and the last problem stay inline. While a browser sign-in is pending the dialog cannot be opened (both flows share one PKCE verifier). Every road — a provider, the e-mail code, the password — asks nothing of the browser or the server where its first connection restored a stored session (account.signIn.restored {method, userId}); the e-mail code has no throttle of its own: the server’s over_email_send_rate_limit is the one, said as rate limited.
The callback. The browser returns to the app through the custom scheme ai.filmopen.desktop. On Windows ensureAuthProtocolRegistered (account_registration_io.dart) registers it for the current user before the browser is opened — three reg.exe add calls under HKCU\Software\Classes\ai.filmopen.desktop, the command being the resolved executable and "%1"; no administrator rights, logged either way — and the model refuses to open the browser when registration is not possible (callbackUnavailable), which is also the answer of the stub and of the other desktop platforms until they are built. scripts/register-login.ps1 is the manual equivalent for a developer who wants to point the scheme at a particular build. The Windows runner (main.cpp) and the Linux runner (my_application.cc, a unique GTK application handling its command line, which hands the arguments on to app_links) hand a second launch’s command line to the running instance and exit, both logging it on standard error — app.secondLaunch {handedTo, outcome, count, args} in the first, app.secondLaunch.exit {pid, handedTo, outcome, count, args} in the second, each argument a URI’s scheme, host and path or other, never a query or a fragment, where a sign-in’s return carries its authorization code —, so the callback reaches the window that started the sign-in. On Android nothing registers at run time (canRegisterAuthProtocol is true on the desktops only): the scheme is the manifest’s intent filter, and the intent that returns the sign-in reaches the account’s state through the link door and the model exchanges it itself (callbackArrived → getSessionFromUrl; account.callbackArrived {initial} in the log, never the link), whether the app was started by it or running, with a client or without — the library’s observer reads a launch link only on the web, so off the web it is told to handle none and no code is exchanged twice; the same link handed twice (app_links gives a launch link to the initial reader and to the stream’s first listener, and on Windows the command line carries it too, which the state feeds in as well) is acted on once. The client configuration (FILMOPEN_AUTH_URL, FILMOPEN_AUTH_PUBLISHABLE_KEY) is baked in at build time and can be overridden with --dart-define; the publishable key is public by design.
The device. At every sign-in, and when a launch restores the session, the model tells DeviceRegistration.current who is signed in — the account’s id, whether the sign-in is anonymous, and a callback that answers the session’s access token, refreshed first when it is past its hour (freshAccessToken) — and at a sign-out that nobody is (§6.6b, §7.1). A launch with a session has one door: the account’s state restores the session as the rail’s profile first builds it — the rail is on every screen — when the accountSignedIn hint is set or the process was started by the callback (account.restore {hint, callback}), the account initialising on the hint, one of the three things that may initialise it; the first connection logs account.connected {session, userId}, tells the seam who is signed in, or clears a hint that outlived its session, and the restore ends account.restored {userId, ms}. A failure is the state’s own notice, logged account.failed {op: restore, ms}. The account knows nothing of how a device registers, and in a test the seam is the no-op one.
FilmOpen credit for a plug-in’s call and for speech (filmopen_account/account_portal.dart). AccountProviderFunding is the app’s funding resolver (ProviderFunding, §7.1), installed by lib/main.dart off the web beside the device’s registration. Asked about a platform and an operation, it answers in this order, stopping at the first refusal, and asks the website nothing: the person’s own key first — any key stored in one of the plug-ins’ key slots on that platform (keyStoredFor: the keys are named by slot, <owner>/<slot>, never by platform), whatever the account holds; then an operation the portal admits: http (kPluginHttpOperation) on any platform, conversation and transcription (kConversationOperation, kTranscriptionOperation) on openai, anything else not configured; the website decides which platforms and paths it funds, refusing an unfunded path or an unconfigured provider; then signed in, else sign in; then this device registered for the account (DeviceRegistration.registeredDevice, §6.6b), else not configured; then a known balance above zero — a known zero is top up, a balance unknown or unavailable is unknown, never read as zero. Every answer is logged, account.funding.decided with the platform, the operation, the scope, the outcome (own, credit, unavailable) and the step that decided it; an own-key lookup that throws is unavailable (unknown), logged own-key-failed with the error’s type, and a bearer read that throws once a credential is held gives no headers, logged account.portal.session failed at the stage bearer with the error’s type and message, and the read or the end it served failed, unsent. Its generation advances as the account’s snapshot changes and as the person’s keys change. One portal credential per operation: the website issues one scope per credential, at a ceiling of up to 100 cents, and refuses an admission above what a credential has left unspent, so two operations never share one. PortalSessions asks POST https://<kFilmOpenDomain>/v1/portal/session with the session’s bearer and the body {device, build, scopes: [<scope>]} — the registered device’s id and the build stamp it was registered with, and the operation’s scope, funded:http for http, funded:openai-realtime for a conversation or a transcription — at the operation’s first portalHeaders, and holds the answer (PortalCredential: the token, expiresAt, the scopes, the spending ceiling in cents, spendCeilingCents) in memory alone, by the operation’s id; the operation’s later requests, its read and its end use it. It is let go when the operation’s end has been sent, whatever its answer, or its receipt has settled, when it expires, when the website refuses it (portalRefused), and at a sign-out (an answer that arrives after the sign-out is held for its operation until the website refuses it or it expires; a signed-out request is refused before any credential is looked up); nothing else is cached. Once an operation holds one, nothing of the person’s own key or the balance is asked again for it: a request of an operation begun on credit stays on credit. A session’s refusal is read by two rules: 401 is sign in; any other — 400, 403, 413, 415, 503 among them — is not configured; an answer not of the contract’s shape, lacking one of its keys or not carrying the scope asked, is malformed, logged with the check that refused it (reason: not-json, not-an-object, token-missing, expiresAt-not-a-date, scopes-not-a-list, scopes-not-strings, spendCeilingCents-not-an-int or scope-not-issued) and the answer itself (answer: the object as the website sent it, its issued token left out, so that no credential reaches the log); an answer that is not an object — an array, a string, a body that is not JSON — is logged by its kind (array, string, number, boolean, null, or text where it is not JSON), its length in bytes and its contentType, never its text, since this door issues a credential and a token cannot be taken out of a shape the contract does not know. Each step is logged with the scope and the operation’s id: account.portal.session.asked, account.portal.session (issued, held, refused, malformed or failed, with the status, the code or the failure’s type, and the milliseconds), account.portal.dropped with the reason (ended, unsent — an end that could not be sent —, settled, expired, refused, signedOut). The bearer is never logged, since the request’s headers are not; neither is an issued credential’s token, nor the refusal’s says.
ProviderFunding.creditReady(platform) answers whether HTTP credit could fund an explicit completion choice now, independently of stored keys. AccountProviderFunding uses only its existing scope, signed-in account, registered device and known positive balance checks. Discovery reads no credential, bearer, HTTP response or operation credential; ordinary funding() stays own-key-first. The inert resolver answers false. Each account query logs its start and funding.creditReady with platform, readiness, reason (scope, signedOut, noDevice, balance or ok), the metadata checked and elapsed milliseconds; an unexpected read failure is logged with its cause and propagated.
Lending a plug-in (filmopen_account/account_lending.dart). AccountLending is the state’s Lending seam (§6.11, Lending), installed by lib/main.dart off the web on the session’s access token. It speaks the website’s supplier operations under https://<kFilmOpenDomain>/v1/lending/ — the offer’s declaration (PUT …/offers/{offerId}, answering the version the website hashed it to, held to the design’s Hash, malformed otherwise), its publication (PUT …/offers/{offerId}/publication, the body {version, published}, a desired state), the device’s availability, the supplier’s inbox (GET …/jobs with no query: this device’s requested and accepted jobs, oldest first, its first page of fifty), a job, read as the website answers it — its input handed up as it came, any JSON, for the supplier to judge, and its result a JSON object or malformed —, and a decision (accept, refuse, fail, complete, cancel, a code with the two refusals, and complete carrying the job’s result, which publishes it) — and the requester’s: discovery (GET /v1/lending/offers, its first page, each offer read as a LentOffer and one not of the design’s shape left out), preparation (POST /v1/lending/jobs, its Idempotency-Key the body’s job id, the body exactly the job id, offerId, version, quantities, input and expectedPriceCents) and the job read back for its result. A job’s input and result are opaque JSON of at most 64 KiB each, which the website stores and hands over without reading: a render’s input is {prompt, seed?}, and its result {model, seed?, stack?, width?, height?, outputs: [{url, contentType, bytes, sha256}]}, each output’s bytes uploaded through the media client (MediaClient, The media client) and named by its download URL. The client holds a result to the 64 KiB bound before it is sent (payload-too-large, nothing sent). There is no result route: no object goes through the lending routes, and the buyer fetches each output from the media store itself (§6.11, Rendering on a lent plug-in). Each goes with the bearer and the registered device’s X-FilmOpen-Device and X-FilmOpen-Build (registeredDevice, §6.6b, the exact stamp it was registered with). Discovery and preparation alone also go under the account’s primary identity: just before each, the client reads the account’s identities through the community’s one client (AccountProjects.identities, below, no cache) and sends the primary’s id as X-FilmOpen-Identity, held to the design’s UUID shape (malformed otherwise); no identity header where the account has no primary; a read that fails refuses the operation with its code (unreachable where nothing answered), so that nothing is listed or prepared under a guessed identity. A replay of a preparation carries the identity its first try did. The header selects; the website’s free match is the account’s — any of its persons the lender names, or a current member of a group the lender names — so a member of a lender’s group through an alias uses the offer free whichever identity is sent. Each discovered offer carries its lender, the supplier account’s primary username, null where it has claimed none. JSON goes as application/json. Nothing is sent without a token (no-token), nor without a registered device (device-not-admitted) but for the account’s own two, which need no device: the supplier’s read (supplied, GET …/offers?role=supplier&offset=…, every page while nextOffset names a later one, every version of every offer the account owns with published true on the selected published one, logged lending.supplied with the number of versions read and the published ones’ ids, versions and plug-ins) and the publication, which the website authorizes by the bearer alone; they carry the device’s headers where one is registered. A request and its answer (lending.request, lending.answered) name the method, the path and the query, and the offer where the operation is about one; a refusal, nothing answering and an answer that is not JSON (lending.refused, lending.unreachable, malformed) carry the request whole — the method, the path, the query, the offer and the JSON body sent — with the status, the answer’s body as it came and the milliseconds where an answer came, a refusal before anything is sent (no-token, device-not-admitted) included. Of the answers that are JSON but not of the design’s shape, the publication’s names its request the same way ({version, published} with the offer and the path); the other operations’ name the operation, the answer and, where there is one, the check that refused it. Every request is logged before it goes (lending.request) and on its 200 (lending.answered, with the status and the answer), by the operation, the decision’s verb, the job’s id, the JSON body sent (the prompt in it) — a body or an answer with a job’s result logged without it, since its outputs’ URLs carry the media store’s tokens — and the milliseconds since the operation began — the identity read as a step of its operation, its answered line naming the primary found, the identity header’s value —, never the bearer or the device’s headers. A refusal is the error envelope’s code, logged as lending.refused with the operation, the verb, the job, the status and the website’s says; a request that nothing answered is unreachable, logged with the error’s type and message; an answer not of the design’s shape, or a job’s id that is not a UUID, is malformed, logged with the answer as it came (a body that is not JSON as its text, with the parser’s error), and the refusal names the check that failed (reason: not-an-object, no-items, bad-summary, and for a job’s summary the rule it broke: state, closeReason, failCode, …) with the operation’s milliseconds; a discovered offer not of the shape is left out, logged as it came (lending.offerLeftOut). lending-unconfirmed is asked again once, by the same request, logged lending.unconfirmed before the second send. The account’s audience (§9.7a): audience reads GET /v1/lending/settings and setAudience writes PUT /v1/lending/settings with exactly {revision, ownDevices: true, freeWith, publicPaid} — the revision last read, own devices the constant the schema requires —, both through the same door as the rest; the answer is read into AudienceSettings, malformed where ownDevices is not true, an id of freeWith is not a Uuid or a field is not its shape; a stale revision is the website’s terms-conflict, refused by its code as any. The public name lookup is the account’s projects client’s (AccountProjects.name, below).
The media client (filmopen_account/account_media.dart). AccountMedia is the state’s MediaClient seam (filmopen_state/media_client.dart; its inert default NoMediaClient refuses media-not-configured), installed by lib/main.dart off the web on the session’s access token. upload(bytes, contentType) turns bytes into a URL a provider or another device can fetch, in two requests. First it mints: POST https://<kFilmOpenDomain>/v1/media, with the bearer and the registered device’s X-FilmOpen-Device and X-FilmOpen-Build and no body, answering 201 with {key, uploadUrl, downloadUrl, expiresAt}, expiresAt an integer of milliseconds since the epoch. Then the bytes go whole by PUT to uploadUrl, with their content type and no other header: the signed URL is its own authority, and the bearer never goes to a URL the client did not build. It answers a MediaObject — the key, the download URL and the expiry; the object lives a day under the account and the bucket’s lifecycle removes it, so nothing is deleted, cached or retried. Nothing is sent without a registered device (device-not-admitted) or a token (no-token; token-unavailable where asking for one threw or outlasted the timeout), nor for empty bytes (empty) or more than 32 MiB, the website’s bound (too-large). A refusal is a MediaRefused with the error envelope’s code and the status; a mint answer (200 or 201) without a non-empty key, two absolute https URLs and an expiry — an integer of milliseconds within a date’s range — is malformed with the mint’s status; a PUT answered other than 200, 201 or 204 is refused by its code; nothing answering, or a timeout, is unreachable. Every stage is logged under one local upload id, with the key once minted and the milliseconds since the start — media.upload.start (with the bytes’ count and the content type), .check, .mint.sent, .minted, .put.sent, .put, .done, .malformed (a mint answer’s field names, key, expiry and each URL’s scheme, or a body that is not JSON as its text), .refused (with its stage, status and the website’s says) and .failed (with the error’s type and message; the PUT’s by its type alone, since its message can quote the signed URL) — and never a URL, which carries its token, nor the bearer.
The account’s projects (filmopen_account/account_projects_client.dart). AccountProjectsClient is the state’s AccountProjects seam (filmopen_state/account_projects.dart; its inert default NoAccountProjects refuses every call account-projects-unavailable, logged), installed by lib/main.dart off the web on the session’s access token; nothing is sent until a screen asks. Six calls, each with the bearer alone — no device, build, identity or idempotency header: identities() (GET /v1/community/self, the primary first), name(username) (GET /v1/community/names/{username} with no bearer at all, so that nothing says who asked — the one client of the community’s public names, which the alias dialog and the share audience both ask: 404 name-not-found is null, 200 the PublicName {id, username, kind}, its id held to the Uuid and its kind to person or group, else malformed, a 404 of another code refused; a name the username grammar refuses (ShortName.validateUsername, §5.6) refused bad-request with nothing sent), claim(username, primary:) (POST /v1/community/identities, exactly {username, claim: 'alias' | 'primary'}, answered 201 {id, username, isPrimary}, a key the website adds beside them read past), createRecord(record) (POST /v1/projects, {id, username, tag, name, provider, location}), records() (GET /v1/projects, {projects: [...]}, one malformed entry failing the whole list) and updateRecord(id, provider:, location:) (PUT /v1/projects/{id}). A record’s answer is read under its project key; a provider this build does not know (dropbox, icloud) is kept as it came. Before a create or an update, a location over 1,024 bytes of UTF-8 is refused location-too-long with nothing sent, and a create’s name is cut to 200 bytes on a character’s boundary. The record’s id is the app’s, made with newUsageId() before the create and kept per folder in the projectRecords preference (Settings.recordOf, SettingsController.keepRecord, {<path>: {id, recorded}}), apart from the recent list, whose cap and menu remove entries; keepRecord awaits the store’s write and answers saved or failed (no-preferences, write-failed), and a create is sent only after saved. The same id sent again is answered 200, read as replayed: true (201 the first time); no record is matched by its path. Every refusal is the envelope’s code alone with its status (alias-limit, name-unavailable, primary-exists, primary-required, project-exists, not-your-project, cloud-data-remains, …), a 401 among them: nothing is refreshed or retried. Nobody signed in is no-token, with nothing sent. Nothing answering, or the session not answering, is unreachable; an answer not of the shape malformed. The log: account.projects.request {operation, call, record?, claim?, name?} before the input check and the token, name the username a lookup asked; account.projects.step {stage: send, method, path, body?} (the JSON sent) before the token is read and {stage: received, status, bytes}; a cut name account.projects.bounded {stage: check, reason: name-cut, bytes}, which carries the call on; and exactly one terminal line — answered {status, replay?, count?, free?, id?, kind?, answer?} with the body answered whole, refused {status, code, says? | stage: token, code} with the website’s words as they came, unreachable {stage: token | send, type, error} with the error’s text, malformed {stage: decode, reason, answer?} with the body answered whole or bounded {stage: check, reason, bytes, code} where the call is refused before anything is sent — each with the call’s id and its ms, never the token or a header. The kept id logs settings.projectRecord.start {path, id, recorded} and one settings.projectRecord {outcome, reason?, type?, error?, recorded, ms}.
Where the account’s records are written from. Settings’ Sharing card — Record on {domain} and Share on FilmOpen Cloud, which records first — and the new-project dialog signed in (§9.2: Drive’s and a local project’s record, best effort, and FilmOpen Cloud’s share), through filmopen_sharing’s SharingModel.recordProject. It keys a record by the project’s folder (a session copy has none: refused noFolder). A kept id already recorded is read back with records() and its username and tag compared with the action’s before updateRecord moves it (identityMismatch, recordMissing and readFailed refuse with nothing changed); otherwise the kept id, or a fresh one, is kept unrecorded and must be saved before every create, every create’s answer — the record as the account keeps it, which a replay does not change — is compared the same way and moved by its id where its place differs, and the confirmation is kept last — where that save fails the outcome is markerNotSaved: the account holds the record and this device could not keep that it does. A lost answer (unreachable) leaves the id kept, unrecorded, for the next press to replay. The places: local the folder’s path, drive FilmOpen/<tag>, filmopen the <username>/<tag> the manifest’s sync names; Share on FilmOpen Cloud records under the viewer’s username. Its log: sharing.record.start {username, tag, provider, location, path, kept, recorded}, sharing.record.step {stage: keep | create | readBack | update | mark, outcome, code?, record?, ms}, the record once there is one; then one of sharing.record.done {outcome, record, ms}, sharing.record.refused {stage, code, record?, ms}, sharing.record.incomplete {stage, code, record?, ms} (unreachable, notSaved, markerNotSaved) or sharing.record.failed {stage, type, error, record?, ms}.
The balance’s whole. AccountBalanceReader also reads balance, an integer of cents as available is, into KnownAccountBalance.balanceMinorUnits (null where unknown); the rail keeps available, and reserved is not read. The read logs account.balance.start, then account.balance.done {status, available, balance, ms}, account.balance.noSession {ms} where no session answers, or account.balance.failed {stage: token | send | decode, status?, code?, says?, answer?, type?, error?, ms}.
A call on credit, its receipt and its end (filmopen_account/account_funded.dart, FundedCalls). A plug-in’s request on credit goes to the website’s pass-through from the host’s HTTP door (§6.11): https://<kFilmOpenDomain>/v1/portal/pass/<platform><path> on the platform’s base origin, or …/pass/<platform>/<host><path> on another host its platform file lists, the provider’s path and query as the door resolved them, its method and body untouched, the plug-in’s content-type and accept, and three headers of FilmOpen’s — the person’s bearer and the operation’s portal credential (ProviderFunding.portalHeaders), and X-FilmOpen-Operation, the operation’s id; the website sets the provider’s key, forwards the request, and answers the provider’s status, content-type and body untouched with X-FilmOpen-Operation-State. No terms are read or copied: what the website prices and what it refuses is its own, and a refusal is its code. The website reads provider-path bodies under its 4 MiB bound; only a server gateway’s encoded upload admits 16 MiB of media plus 1 MiB of framing, with the HTTP response bound unchanged. The account speaks the operation’s two routes itself, each with the bearer and the operation’s credential and each sent once: ProviderFunding.receipt reads GET https://<kFilmOpenDomain>/v1/portal/operations/{id} — its receipt (outcome, chargedCents, releasedCents, observedExpenseCents, receiptId) once the operation has settled, which re-reads the balance; none while it is pending, or where the read is refused, malformed or failed — and ProviderFunding.end posts POST …/operations/{id}/end, its outcome logged and nothing waiting on it — the speech sessions’ settlement, and for a render on credit sent at the person’s Stop, once per operation — at once where a call of the job is in flight, so that it reaches the website before that call’s next request, else by the job’s cancel whatever the plug-in answers — before its receipt is read, so that the website cancels the job, frees the hold at once and the receipt read is the terminal one (§6.11); a request on credit the stopped job asks after the End began waits for it and is refused here, operation-ended, with nothing sent (plugin.http.credit.refused), and every operation’s receipt then read is the job’s confirmed end, its cost the receipt’s; never for a job the person did not stop, whose result is still wanted, since the website refuses a result after End (409 operation-ended). Both take the operation’s kind, which selects the credential’s scope, since an OpenAI image and a conversation may be open at once; where no credential is held for the operation — a read after a restart — one of the kind’s scope is asked anew. A 401 of the portal credential (no-portal-token, bad-portal-token, kPortalRefusalCodes) lets the credential go and nothing is sent again. Each logs its send and its outcome with the platform, the kind, the operation’s id and the milliseconds: account.portal.receipt.sent, account.portal.receipt (settled with chargedCents, receiptId and the website’s answer whole — its tariff, units and basis —, pending, refused with the status, code and says, malformed with the answer as it came, failed with the failure’s type and message), account.portal.end.sent, account.portal.end (ended, refused with says, failed) — never a header, a token or the bearer.
Groups (filmopen_account/account_groups.dart). AccountGroupsClient is the state’s CommunityGroups seam (filmopen_state/community_groups.dart: GroupName, GroupMembership with its identityId, group and role, GroupMember, GroupInvitation, GroupPage, GroupDetail, CommunityGroupsRefused; its inert default NoCommunityGroups refuses every call not-signed-in, logged community.groups.refused with the operation and its arguments), installed by lib/main.dart off the web on the session’s access token; nothing is sent until the GPU page asks. Nine calls on the website’s community routes, each with the bearer, and with X-FilmOpen-Identity where a route acts as an identity — always the membership’s own identityId, never the primary: groups() (GET /v1/community/self/groups, no identity), create(username) (GET /v1/community/self for the primary, which is the creator, then POST /v1/community/groups {username} as it, 201), group(membership) (GET /v1/community/groups/{g}: the roster for an owner or an admin; a member’s answer has none, which is no failure), invite(membership, username) (POST …/groups/{g}/invitations {username, role: member}, 201), invitations(membership) (GET …/groups/{g}/invitations, a manager’s), received() (GET /v1/community/self/invitations: the pending, unexpired invitations to any of the account’s usernames that may be accepted now), accept(id) and decline(id) (POST /v1/community/self/invitations/{i}/accept and …/decline, no body and no identity: the bearer owning the invited username is the authority, the mailed link’s secret not needed; a decline ends the invitation as revoked), and leave(membership) (DELETE …/groups/{g}/members/{identityId}; the website refuses the last owner last-owner). Lists take an offset and keep hasMore; an id goes into a path only as the contract’s Uuid. A status other than the route’s success is refused by the envelope’s code and status; nobody signed in is no-token with nothing sent, no primary username no-primary; nothing answering unreachable; an answer not of the contract’s shape malformed; nothing is retried. The log: account.groups.request {operation, call, …inputs} (the group, its username, the acting identity and role, the username typed, the offset, the invitation); account.groups.step {stage: send, method, url, identity?, body?} once the token is in hand, {stage: received, status, answer} and {stage: actor, identity} on a create; and one terminal line — answered {status, count?, answer}, refused {status?, code, says?, answer?}, unreachable {stage, type, error} or malformed {reason, answer} — each with the call’s id and ms, never the token.
Credit waits and preparation. Bearer, portal session, balance, receipt and End requests have no application deadline: they end on an answer, a transport failure or the turn’s Stop, which aborts the in-flight request and prevents late provider dispatch. An End caused by Stop is sent without that turn’s cancellation and is not awaited by the person. A completed call’s background receipt and its closing balance refresh also run independently of the turn’s cancellation. A stopped operation’s background reader waits for its already-started End before reading the receipt, without delaying the dock or another call. After a text call is admitted, the next operation’s session is prepared while the current call runs. The next call takes the prepared operation while its request is in flight or its session is ready and unexpired; otherwise it acquires a session normally. The used log distinguishes taking a prepared operation from that session being ready. An assistant completion’s session serves one call, retains the website’s ten-minute expiry and 100-cent ceiling, and reserves nothing before admission. credit.session.preparing, credit.session.prepared and credit.session.used record the turn, call, distinct operation, readiness, expiry and elapsed time. Authorization, agreed prices and duplicate-charge checks remain the website’s.
6.6a The build’s stamp — filmopen_state/build_stamp.dart, lib/consts.dart
Section titled “6.6a The build’s stamp — filmopen_state/build_stamp.dart, lib/consts.dart”What a build says it is, made when it was built (the website’s roadmap §3.7, so that a local build says when it was made and for which platform, and two development builds are two lines on the website’s Devices tab). One pure function makes it from the version and three defines, buildStamp:
0.1.0+417 (abc1234) windows 2026-09-19T14:03:22Zfrom CI — the version,+and the run (FILMOPEN_BUILD), the commit in brackets (FILMOPEN_COMMIT), the platform, the moment (FILMOPEN_BUILT_AT);0.1.0+dev (local) linux 2026-09-19T14:03:22Zfrom a local script —devandlocalwhere CI’s defines are unset;- the same without the moment from a build given none (§3’s Build stamp row).
The platform is the one the build runs on, web or the target platform’s name in lower case (runningPlatformName): a build runs only on the platform it was built for. The stamp carries no words, so it reads the same in every language, and is printable ASCII of at most 120 characters whatever a define says — each part keeps only letters, digits, ., _ and -, cut to its length, and a moment that is not an instant in UTC to the second (a real date, no zone but Z, no fraction) is left out — because the website shows it verbatim. appVersionWithBuild is the version and the build, 0.1.0+dev, at most 40.
Where it shows. Settings → About and the support dialog’s About tab show FilmOpen <stamp>, selectable so a person can copy it into a report; the licence page shows the version with its build; the log’s app.started carries it as stamp.
6.6b The device’s key — filmopen_device
Section titled “6.6b The device’s key — filmopen_device”Which device this is, for the website’s Devices tab (its), the portal token that will be bound to a registered device (the website’s R4) and the render relay’s share (R10). Not a MAC address, an IMEI or a serial number — a current Android or iOS gives an app none of them, the stores forbid collecting them, and a desktop’s are easily forged — but an id the installation makes for itself: a P-256 key pair (ECDSA with SHA-256; P-256 because Apple’s Secure Enclave holds nothing else, so a key made there later is the same kind), its private half kept in the device’s credential store under filmopen.device.key.v1 (CredentialStore.current, §6.7: DPAPI, the Keychain, the Keystore, the Secret Service — where the Portal Specification’s P14 keeps a person’s provider keys), and the device’s id the SHA-256 of the public half, 64 lower-case hex digits. It is permanent for the installation, survives updates and every sign-out, and can prove itself.
DeviceKey (device_key.dart) is made from 32 bytes of Random.secure, drawn again until they are a scalar below the curve’s order; it gives its id, its public key as the website takes it — the 65-byte uncompressed point, 0x04 || X || Y, as canonical unpadded base64url, 87 characters — and sign(text): ECDSA over the text’s UTF-8, deterministic (RFC 6979), as the raw 64-byte r || s in canonical unpadded base64url, 86 characters, never ASN.1 DER — the website verifies P1363 with WebCrypto, and its contract makes converting the app’s work. What the store keeps is the private scalar, 32 bytes, as base64url; a value that is not one is not a key.
openDeviceKey (device_key_store.dart) gives the key the store keeps, or makes one. A key is made only when the store answered that it holds nothing: a read that failed gives none (unreadable), since a locked keyring that minted a key at every launch would add a device to the person’s list each time; and a key made is used only once the store has given it back as written (unwritable otherwise), so a key the store failed to keep never registers a device that could not return. A value under the name that is not a key is written over, and said to be. A store whose door refused the new key’s write as emptyStore — it returned none of the names it keeps (§6.7) — gives none (emptyStore): no key is made over the keys it lost, and the registration is skipped with that reason. Each step is logged — device.key.open, device.key.read {found, parsed, id}, device.key.made {id, replacing}, device.key.kept, or device.key.failed {problem, stage, type, cause} with the stage (read, write, readBack) — with its milliseconds, the key’s id and never its text. A write the platform reported as failed is unwritable, whatever the store holds afterwards: a native error is never read as saved. Two calls for one store while the first is in flight share its answer, so a launch and a sign-in on a first run make one key between them. The store’s own failure never travels, since its text can quote what it was handling.
The key is made in Dart and kept in the store, not made inside the Secure Enclave or the Android Keystore, which would need native code on each platform; the contract’s P-256 keeps that move open (§16). It is made the first time it is needed — the first registration — and not before the first frame, since nothing touches a plug-in’s channel at start (§6.6).
Registering the device (device_registrar.dart, website’s PUT /v1/devices/{id}). At every sign-in and every launch that restores a session the account tells the seam (§6.6, §7.1), and DeviceRegistrar — installed by lib/main.dart off the web — sends one request to <kFilmOpenSiteUrl>/v1/devices/<id>: the person’s token as authorization; a JSON body of five fields, publicKey, platform (runningPlatformName), name (left out when there is none), appVersion (0.1.0+dev) and buildStamp (§6.6a); and the proof in two headers, x-filmopen-device-signed-at (whole seconds) and x-filmopen-device-proof, the key’s signature over six lines — filmopen-device-registration-v1, the site’s origin, the account’s id, the device’s id, the moment’s characters as sent, the SHA-256 of the body’s bytes — so that a proof made for one site, one account or one body is none for another. Builds are not signed on the development site (the website’s R2): the request carries no build signature, and the site shows the stamp as the app reports it, unverified.
- Once per sign-in, once per launch. The card’s model is rebuilt whenever Settings changes tab and restores the session each time, so the registrar remembers the account it is registering or has registered, in flight included: a launch and a sign-in together send one request, a sign-out forgets it and the next sign-in sends again. A request whose account has gone — signed out, another signed in, or an anonymous sign-in — is dropped before it is sent. A card that connects and finds no session tells the seam nobody is signed in: a sign-out the sign-in library made on its own, with no card open to hear it, must not make the next sign-in of the same account look like one already registered.
- Bounded. The token is waited for no longer than the request (15 seconds): a refresh that never answers would otherwise hold every later request behind it.
- What is forgotten, and what remembered. A request that could not be made — no network, a timeout, the server’s own failure (
5xx), a store that could not be read, no token — is forgotten, so the next restore tries again; a refusal —400,401,403,405,409,413, and any other answer below500, a redirect included, since none is followed — is remembered for the process, since it would be refused again. Nothing about the answer is shown: nothing about signing in depends on this route answering (the website’s K12). The device as registered is the seam’s one fact (registeredDevice): its id and the build stamp sent, from the website’s200or201for the sign-in in force until a sign-out or another account’s sign-in; null on a build that registers none. What is sent is said: where a sign-in would register the device — the seam’sregisters: a platform the website knows and a store that keeps keys — the account’s popup says under its first sentence that signing in also registers the device, its name, its platform and the app’s version and build, and that the website’s Devices tab lists it and forgets it (accountDeviceNotice); the web build, which installs no registration, and the agent build, whose keys live in memory, say nothing. - A proof is good once. The website takes a moment only when it is within five minutes of its clock and after the last it accepted from the device. Requests go one at a time, each signed at least a second after the last one accepted;
stale-proofandproof-replayedare signed again once, a second after the refusal’sserverTime— and never at or before a moment the site accepted, which a request it took after the device had stopped waiting can have made later than its clock — and the offset of the site’s clock is kept for the rest of the process. A timeout gives up waiting, not the request. - Not registered: an anonymous sign-in (a session nobody can return to); a platform the website does not know; a build with no credential store (the web); and a store that forgets — the agent build and the tests keep keys in memory (
isEphemeral), and a key that ends with the process would be a new device at every launch. - The name a device suggests (
device_name*.dart, in threes) is the host name on Windows, Linux and macOS, and nothing on a phone, whose host name islocalhost, or on the web; without control characters or halves of a character, nor a line or paragraph separator, at most 80 code points (deviceNameText, as the website’s own check). The person’s own name, given on the website, wins over it. It is personal data (the website’s K15), logged with the registration as the rest of the person’s data is. - The device’s permanent name, its slug (
device_slug.dart,claim_device_name_dialog.dart). A chat app’s connector address names the device by it (§6.6c), so it is claimed once and never renamed:^[a-z0-9][a-z0-9-]{2,31}$, unique among the account’s devices. It rides the signed body as a sixth field,slug, so the proof covers it.claim(slug)on the seam checks the grammar first (bad-slug, nothing sent) and registers again behind any registration in flight, sending the candidate; the website’s200/201answers the slug it holds, which is the one kept — under<accountId>/<deviceId>/slugin the preferences, a key carrying the account, so a name another account claimed on the machine is never read — and published as the seam’sslug; an answer without one clears it. A claim that was refused or could not be made keeps nothing, so a cancel after it claims nothing later. Refusals are read fromerror, neversays, and answered by their code:409 slug-taken,409 slug-immutable,400 bad-slug; the same refusals of a kept name drop it. Every later registration sends the stored name; after the person forgot the device on the website, which clears its name there, the next registration claims it again, and if another device took it meanwhile, the refusal drops it and the device stays unregistered until a new name is claimed — Remote control’s switch, usable while signed in, asks for one and its claim registers the device — or the next launch. TheClaimDeviceNameDialogproposes the host name lowered and reduced to the grammar (proposeDeviceSlug), lets the person edit it, claims, shows a refusal inside itself and closes answering true once the website took the name;claimDeviceName(context)opens it only where the device has no name, and the root hands it as a callback to Remote control‘s switch (§6.6c) and Share my plugins’ first publication (§6.11). - The log says what happened in codes —
device.keyMade {replaced},device.registered {status, platform, slugSent, slugAnswered, name, slug},device.claim.start {slug, registered, had},device.claim.proof {attempt},device.claim.done {status, code, ms},device.slugDropped {code},device.slugNotKept {slug, type, error}(the device stays registered with its name for the session: a preference that cannot be written loses it for the next launch alone),device.claim.dialog {outcome, checked, slug?},device.clockOffset {code, seconds},device.refused {status, code, body}(the code theerrorfield only when it has the shape of one, elseunreadable; the body whole),device.failed {type, error},device.skipped {reason}— and never the token, the private key or the proof.
A live registration needs the site to accept the app’s sign-in: the development Worker verifies tokens of its own Supabase project, and the app signs in to the project FILMOPEN_AUTH_URL names (§6.6) — by default that very development project.
6.6c Remote control — filmopen_relay
Section titled “6.6c Remote control — filmopen_relay”What it is. With Remote control on, the person’s chat app reaches this device’s commands (§7.3) through the website: the device holds one socket to wss://<kFilmOpenDomain>/v1/relay/socket, publishes its catalogue there, and answers what the website forwards. The same socket carries the lending notices (§6.11) to a device that lends, which is why it is held for lending alone too.
- Eligibility. The socket is held while all hold: an account signed in (
accountNamesProvider’s account) on a registered device (DeviceRegistration.current.registeredDevice), the app in the foreground —AppLifecycleState.resumedorinactive, since a desktop window without the focus isinactivewhile the person reads their chat app beside it; hidden, paused and detached are the background — and Remote control on orLending.publishednot empty. Commands are a separate matter: they are offered and run only while Remote control is on, a per-device preference (relay.remoteControl), off by default; turning it on asks for the device’s name first where it has none (§6.6b). The root callsRelayClient.reconsiderat every change of these — a registration, the account’s names, the lifecycle — and the client listens toLending.publisheditself. - The lifetime. One
RelayLifetimeper connection attempt, numbered from 1 in the process, is made when the socket becomes eligible or the 10-second retry fires, and ended when eligibility ends, another account signs in, the attempt fails, or its socket drops or stops; a call of an ended lifetime not yet admitted is refused at admission (stopped) with no effect, while one already admitted completes and keeps its ledger answer, its output dropped (R8); its socket is closed with1000and its timers cancelled. Every timer, pending call and answer carries it, and a frame or an answer of an ended lifetime, or of a socket it has since replaced, goes nowhere. - Connecting. Each attempt reads the token and the device again and sends
Authorization: Bearer,X-FilmOpen-DeviceandX-FilmOpen-Build, as the lending routes do.dart:io’sWebSocketgives a refused upgrade’s HTTP status and not its body, so a refusal is known by its status:503is transient, any other (401,403,400,405,426) stops with the status on the card. A network failure, no token yet and any close other than the four below are transient: one attempt every 10 seconds while eligible, with no dialog, no message and no ladder — the card says Connecting…. Apingtext frame goes every 25 seconds. - Closes.
4001(another socket of this device took over) stops it, the card saying Another copy of this device took over;4002,4003and4004stop it with their code; a stop for403or4003— the website no longer admits the device: forgotten, blocked or another build — says so on the card (§9.4), since nothing in the session registers it again. A stop holds until the switch is turned on again or another sign-in: going to the background and back does not end it, nor does the switch turned off alone, which with a lending offer published would take the device back from the copy that superseded it. - The catalogue, sent on open, at every
CommandRegistry.changesand at every turn of the switch while the socket stays open, at most one a second:{type: catalogue, name, version, tools, methods, capabilities, instructions, device: {platform}}— with Remote control on,toolsevery exposed command astoolOfnames it (<source>.<name>, the separator.),methods: ["resources/read", "resources/list"],capabilities: {resources: {}}andCommandRegistry.instructions(), which namebuiltin.sessionwith the same separator; with it off,toolsandmethodsempty and no instructions or capabilities.nameis the device’s host name where it has one. callframes run, while Remote control is on, throughCommandRegistry.callasCommandCaller.relaywith the frame’s deadline and a guard that holds while the lifetime lives and the switch is on (R8): the tool’s name is split at its first.. Theresultframe goes back while the lifetime and its socket hold; the whole serialized frame is sized, and one over 1 MiB is sent as atoo-largerefusal instead, the ledger keeping its own answer. With the switch off, a call is answerednot-remoteand nothing is dispatched.requestframes:resources/readof anhttps:link the app minted for a render’s picture (mintedMedia,filmopen_render_ui) answers one content item — the URI, its MIME type, the bytes asblob— withttlMs, the link’s life left, andcacheScope: private;resources/listanswers, in one page whatever its cursor, every such link not yet expired (mintedLinks), each with itsuri, anameoftakeand its place in the list, never an entity’s name or a prompt, and its MIME type, withttlMs: 0andcacheScope: private, its log line naming each link’s URI whole, its query included, its name and its MIME type; a read’s line names the URI asked, whole, and what it answered: the MIME type, the bytes andttlMs. Both answer only the links minted under the command authority now in force — the account, the viewer and the film of the call that minted each, never of a later reader — so after a sign-out, another account, another viewer or another film the earlier links are hidden, not deleted, and read as an unknown one; an unknown URI is-32002, any other method-32601, and with the switch off every request-32601; a link lookup that throws is answered-32603internal, never with its text, its log line naming the stage, the error’s type and its message. A response over 1 MiB is-32603too-large.eventlending.job, heard whether or not Remote control is on:role: supplierwakes the lending supplier’s loop (pokeLendingSupplier),role: requesterpolls that job now (RenderManager.pokeNow). It carries the job’s id alone; the authenticated reads stay authoritative and the polls stay.- Anything else — another type, a frame that is not JSON, a binary frame — is logged
unknownby its kind and ignored;pongis the runtime’s answer to the ping. - Not guarded, on purpose: no queue of calls while offline, no journal, no backoff beyond the 10-second retry, no socket in the background, and the one-a-second catalogue the whole of its coalescing.
- The log:
relay.remote {on}, thenrelay.remote.saved {ok, ms}or{ok: false, type, error, ms}— a setting that could not be kept is said on the card and lasts the session;relay.addressCopy.start, thenrelay.addressCopied {ok, ms}or{ok: false, type, error, ms};relay.eligible {signedIn, registered, foreground, remote, published, eligible}when one of them changed;relay.lifetime {lifetime, state, reason};relay.connect {lifetime}, thenrelay.connected {lifetime, used, ms}orrelay.refused {lifetime, code, message?, transient, type?, error?, ms};relay.catalogue {lifetime, remote, tools, bytes}; per framerelay.call {ref, lifetime, remote},relay.request.start {ref, lifetime, method},relay.event {ref, lifetime, kind, role, job, outcome, ms}orrelay.unknown {ref, kind, type, frame, bytes};relay.result {ref, frameBytes, outcome: sent|too-large|dropped, ms}andrelay.request {ref, method, outcome, bytes, ms}with the method as it came and a failure’stypeanderror;relay.closed {lifetime, code, reason: transient|superseded|refused}, thenrelay.reconnect {lifetime, after}orrelay.stopped {lifetime, code}. Never the token or a header.
6.7 Provider keys and the usage log — filmopen_platform/credential_store.dart, key_verifier.dart; filmopen_keys; filmopen_format/usage_record.dart, usage_totals.dart, money.dart; filmopen_store/usage_log*.dart
Section titled “6.7 Provider keys and the usage log — filmopen_platform/credential_store.dart, key_verifier.dart; filmopen_keys; filmopen_format/usage_record.dart, usage_totals.dart, money.dart; filmopen_store/usage_log*.dart”The OpenAI platform file defines a signed-in route beside its existing API key,
labelled ChatGPT plan. Initial sign-in uses dynamic client registration and
the installation’s persistent UUID host identifier. The grant requests identity,
offline access and Responses API permission, and requires
chatgpt.tokens.use.direct before storing the credential. Code exchange and
refresh both name https://api.openai.com/v1 as their resource. Signed-in calls
use the same API host and credential path restrictions as the key route, and
omit the output cap. Eligible Plus and Pro accounts authorize plan usage in
OpenAI’s browser flow; a valid identity alone does not grant that usage.
The OpenAI plug-in keeps its render key first and declares the ChatGPT sign-in
slot after it. Its card uses the generic sign-in and sign-out doors. Text uses
the stored ChatGPT sign-in before Codex or the API key; a refused plan request
does not switch routes. Status checks the stored sign-in’s model list without
making a model call. Signed-in text sends system context as instructions,
function tools as an additional_tools input item, and tool calls and their
outputs in conversation order. It always streams HTTP with store: false,
requires a completed response, and keeps OpenAI’s refusal codes and words.
A 401 asks the person to sign in again. Usage records the host’s plan label
and any returned tokens without USD; an answer without token counts invents none.
Recorded on 15 September 2026. The owner decided or directed the following (Portal Specification P1–P9, P14, P17 and P20):
- Usage is tracked on the device first, on the user’s own keys.
- The platforms.
- OpenRouter for text.
- fal for images, video and, for now, ElevenLabs’ voices.
- OpenAI for live transcription.
- Every other platform stays in the catalogue, marked pending, with its obstacles.
- The code is shared, so power users can add their own platforms and keys.
- Keys.
- Keys are kept by the operating systems’ standard credential stores, and never in a project.
- The Provider keys tab explains how keys are saved encrypted on the device.
- It does not show a key again once it is entered: “we won’t worry about showing the user his key after he enters it”. A user who wants to share a key shares it themselves, or uses filmopen.ai.
- Credentials that filmopen.ai hands to devices, later, go only to FilmOpen’s own signed builds.
- Usage records.
- Usage is a “very simple append-only transaction log”, of JSON objects or whatever is standard, with no SQL, no relational database, and no merges or joins; at most, the app filters it.
- A Usage tab shows each use, what it was for and what it cost.
- filmopen.ai, later, keeps the same records; the only difference shown is the route, such as “fal via filmopen.ai”.
- There, a director can set collaborators’ daily, weekly or monthly limits.
Platforms. The live platforms are those assets/models/README.md names: besides the first version’s, xAI, Anthropic’s Claude on a workspace-scoped key or on the person’s own Claude Code (fo-anthropic: the CLI they installed and signed in to, run with only FilmOpen’s tools, counted against their Claude plan with no FilmOpen charge), Gemini on a Google AI Studio key (fo-google, recorded as any key route: never labelled a plan, whatever a subscriber’s monthly Cloud credit later covers on Google’s bill), ElevenLabs, Atlas Cloud and the local Wan2GP. Every other platform in the catalogue is marked as pending.
The speech services’ providers (filmopen_state/speech_providers.dart). Dictation (§9.6) and the voice conversation (§6.11a) keep their own Dart routes and read their keys from two provider plug-ins, two fixed bindings defined once: the platform openai → the plug-in fo-openai, its slot openai, kept under filmopen/fo-openai/openai; the platform xai → fo-xai, its slot xai, under filmopen/fo-xai/xai. Each key is entered on its plug-in’s page (§9.4), the one place it is edited. The store name comes from the binding’s fixed tag and slot, never from the plug-ins that are on now, and no credential is found by a plug-in’s name or by the first plug-in on a platform. Before a new session reads its key, and again after the read, before a microphone opens, the provider must be installed, allowed, on and runnable, and declare its slot on its platform (SpeechProvider.ready, read from PluginCatalogueView.current); otherwise the session is refused with the code provider and one sentence naming the provider and its page (OpenAI is not ready: install it and turn it on in Settings → Plugins → OpenAI, then try again.). The person’s choice of service stays as it is. A missing key, a malformed record and an unreadable store keep their own refusals. Dictation’s refusal carries a door to the provider’s page (settingsTabProvider’s openPlugin); the conversation’s names the page in its sentence. The conversation’s funding is the provider plug-in’s (FundingBinding.providerTag fo-openai or fo-xai), while its route keeps the platform. A record kept under an earlier name of the app’s own (filmopen/app/<platform>) is never read, moved or deleted: a key is entered again on the provider’s page.
Provider keys. A user’s key is kept in the device’s credential store through flutter_secure_storage, as the account session is (§6.4): a file encrypted with DPAPI on Windows, the Keychain on Apple platforms, the Android Keystore, libsecret (the Secret Service) on Linux — all through the one door below.
An OAuth manifest slot uses the same slot’s credential-store name and preference record. Its credential holds the access and refresh tokens, expiry, client id, installation’s UUIDv4 host id, made once and kept in host preferences, account and granted scopes. The preference record contains the stored status, time and record id, with the account label and expiry for the card; it contains no token. A settings action signs in or signs out through the generic Dart door (§9.4). Sign-out clears both records only after revocation answers HTTP 200; a refresh response with a 4xx status other than 429 clears both and requires sign-in again. A 429, a 5xx or any other unsuccessful response fails the call honestly and keeps both records, without retry or route fallback. When a successful token exchange omits scope, the granted and stored scopes are the requested scopes. An OAuth card uses its manifest slot’s host sign-in/sign-out doors directly and reads account/expiry metadata alone. It offers Sign in when no record exists, including after a refused refresh clears it; that refusal is recorded in the refreshFailed/cleared log events and explained in the failed call’s dock error. The session remains stale and refuses further sends. No history is retained on the card. An OAuth slot on its owner’s own platform gets a sign-in card when the platform has an OAuth block, including when its ordinary authentication kind is none.
The installation UUID is substituted bare for {host_id} in authorization parameters and optional token form parameters; the platform supplies any prefix. Token parameters accompany code exchange and refresh. The slot lock covers the whole refresh, including its network wait, so another expired call reads the refreshed record and sign-out queued during refresh revokes that refreshed record. No stale-record comparison follows the serialized response. Not guarded: a call waiting behind a hung refresh must wait for the first call’s Stop to release the slot lock; it then runs with its own cancellation. No separate queue cancellation path bypasses the lock. A client is registered again after sign-out where its provider requires it; no client mapping survives the credential record.
- The seams (
filmopen_platform), each a static shaped likeAutomationHost.install(§7.1) and unavailable until an entrypoint installs another, so that a build that installs nothing fails closed: CredentialStore(credential_store.dart):read,writeanddeleteof one name,isAvailableandisEphemeral, with no way to list or clear the store, which holds the account’s session too.DeviceCredentialStore(device_credential_store_io.dart) callsFlutterSecureStoragewith the name verbatim, one call at a time, and turns any failure intoCredentialStoreUnavailable, which carries the failure’s type only; its log linecredentials.failedadds a platform failure’s own code (KeyringLockedfrom a locked Linux keyring, never its message or details; on Windows the HRESULT with the plugin’s fixed sentence, such asFailure on CryptUnprotectData(), or the system’s text for the HRESULT). Each caller has ten seconds, in the queue and in the native call together: on Windows the plugin retries a failed file write without end, so a caller past that fails closed astimeout. A native call already begun keeps the queue until it ends, so an older value is never written after a newer one; a queued call whose caller timed out before it began never runs (§16).MemoryCredentialStorecounts its calls, for the tests and the agent build. The web build’s store is the unavailable one.- The one door, its inventory and its guard. Every secret the app keeps goes through
CredentialStore.current: the provider keys, the account’s session and PKCE verifier (filmopen_account’s storage adapters), Drive’s tokens (SecureDriveTokenStore) and the device’s key (§6.6b); nothing else callsFlutterSecureStorage. On Linux and Windows the plugin keeps every name in one item — one Secret Service item, one DPAPI file — read and written back whole on each write, and a read that silently comes back empty (a lookup that misses the item, a file that is gone) turns the next write into a write of that one name over all the others. SoDeviceCredentialStorekeeps an inventory: the names it has written and not deleted since, never a value, incredentials.inventory.jsonbeside the preferences (FileCredentialInventory, the application-support folder). A successful write adds its name, a successful delete removes it. Where no inventory exists yet, the first call seeds it from the names the store holds (readAll), loggedcredentials.inventory.seeded; a store that reads empty then is a fresh install. The guard — never write over a valid key file: before every write the door reads the plugin’s whole object (readAll), and the write goes ahead only where it holds every name the inventory expects, or the inventory is empty (a fresh install, or after the person started again); otherwise it is refused,CredentialStoreUnavailable('emptyStore')where none is there andpartialStorewhere some are missing, loggedcredentials.writeRefused {name, expected, present, missing, reason, pid}, and nothing is written. Deletes are never blocked. Every call reads the object first and logscredentials.inventory {op, name, expected, store, present, missing}— the names the store answered with, never a value — and the run’s first call logs it once more ascredentials.startupwith the process id (the check runs at the first call rather than at the app’s start, since nothing touches a plugin channel at start). Itshealth(CredentialHealth:emptyStore,partialStore,inventoryUnreadable, the inventory’smissingnames, and every name a read found absent this run, whether or not the inventory names it) changes with each check; a check that finds every name clears the refusal, and a name read absent stays so for the run until a read finds it or a write puts it back. An inventory file that is there and does not parse is not an absent one: it is loggedcredentials.inventory.unreadable, reads go on, and writes are refusedinventoryUnreadableuntil the person starts again. One FilmOpen per person: a second launch hands itself to the first (Windows:windows/runner/main.cpp,SendAppLinkToInstance; Linux: the GTK application is unique and handles its command line,linux/runner/my_application.cc), so one process writes the store; agent builds run on a session bus of their own, or none, and never hand themselves to the person’s. Each call still reads the inventory from disk; it is saved through a temporary file of its own name (the pid, the time, a count and a random part) and a rename, and it lists what the keyring line says: an unreadable one reads FilmOpen’s list of the keys it saved could not be read.resetInventory— the person’s These keys are gone: start again — keeps of the inventory only the names the store still holds — every name it holds, where the inventory could not be read — so a later loss of those is still caught, loggedcredentials.inventory.reset {names, kept}. A delete takes its name out of the inventory before anything else, so a key the person removed is never said to be missing. The guard does not cover the gap between its check and the plugin’s own read inside the write; on Apple platforms each name is its own Keychain item and the whole-item risk does not arise. Every call logscredentials.callwith the process id and, for a delete, the caller’s first frames outside the door, so the log names who deleted;credentials.nativewith the queue’s milliseconds, andcredentials.donewithfoundfor a read (never the value or its length), the inventory’s size before and after,queuedMs,nativeMsandms; no code calls the plugin’sdeleteAll, which a test pins; the door’s own refusal iscredentials.refused, a platform failurecredentials.failedwith its type, its native code and, on Linux, the phase its message names (lookup,storeorwarmup), never the message itself. - The account and Drive touch their own names alone. The account’s adapters (
SecureSessionStorage,SecurePkceStorage) write and remove onlyfilmopen.supabase.session.v1andfilmopen.pkce.<key>;supabase_flutterremoves the stored session only at a sign-out — the person’s, or a refresh its server rejected with a 4xx — and a refresh that failed for want of a network (AuthRetryableFetchException, a 5xx among them) leaves it. Every answer of the auth server’s token endpoint is loggedaccount.token.answered {grant, status, code}(a refusal’s body with it, never a 2xx body) and a token request nothing answeredaccount.token.unanswered, so a removal is told apart from a network that blinked; a refresh counts as refused only where the answer is the auth server’s own refusal of it — 400, 401 or 403 withrefresh_token_not_found,refresh_token_already_used,session_not_found,session_expired,bad_jwtoruser_not_found(undercodeorerror_code, as the library reads them), or the legacyerror: invalid_grant—; any other non-2xx answer to a refresh (a captive portal’s redirect, a proxy’s or a gateway’s page, JSON or not), and any other grant’s answer without an error code, is loggedaccount.token.notTheServerand thrown as a network failure, which the library retries and which never removes the session. Drive’s tokens are cleared only by its sign-out; a refresh that failed, refused or unanswered, keeps them. A key entered before any account exists stays through every sign-in, sign-out and refresh. - The keyring line (
filmopen_keys/keyring_line.dart,KeyringEmptyLine): where the store’shealthsays it is refusing writes, one line at the top of Settings and in the assistant dock says that the store — the system keyring on Linux, FilmOpen’s protected file on Windows, the Keychain on Apple platforms, the Android Keystore — returned none of FilmOpen’s keys — or only some, naming the inventory’s missing ones (a plug-in’s by owner and slot; your filmopen.ai sign-in, your Google Drive sign-in, this device’s key) — and that nothing was changed, with These keys are gone: start again, which asks first and then callsresetInventory; loggedkeys.keyring.shownonce per show andkeys.keyring.startAgainwith each step. A card whose record names a key the store’s reads found absent says Missing from this computer’s keyring and Enter it again in the box below, loggedkeys.card.missingonce per show; showing a card still opens no store. The line names only missing provider keys whose platform has no explicit FilmOpen credit route; account, Drive, device and unknown missing names remain warnings, and an unreadable inventory always remains a warning. Where credit covers every missing name, Settings and the dock instead say Signed in, using FilmOpen credit. in ordinary text, retaining the confirmed These keys are gone: start again action and the shown log while the store refuses writes. Where dock readiness already supplies the credit sentence, the keyring line keeps recovery without duplicating that sentence. A key card on a credit route shows the same information instead of a missing-key status. These display rules do not change the credential store’s write guard or reset confirmation. KeyVerifier(key_verifier.dart):verify(platform, values)answers aKeyCheck,verified,rejectedorcouldNotCheck, with a code.StubKeyVerifieranswers by how a fake value ends (-okverified,-badrejected, anything elsehttp_503), for the tests and the agent build.- Who installs what:
lib/main.dartinstallsDeviceCredentialStoreandHttpKeyVerifier, unless an entrypoint outsidelib/installed others first;test_driver/agent_main.dartand the tests’pumpAppandpumpSettingsinstall the memory store and the stub, and the tests put the defaults back; the agent build’s live keys and stand-ins installHttpKeyVerifierin the stub’s place, its store staying the memory one (§4.3). - How it is stored. One JSON object, a field per credential the platform file lists (
{"key": "…"}), each value trimmed of spaces and of the invisible characters a copy can bring along (a zero-width space, a byte-order mark), under its key slot’s name,filmopen/<owner>/<slot>(Project Specification §14.9, where the platform file’sauth.keychainis the base the app adds the owner to), the owner always a plug-in’s tag: the app owns no key of its own. The speech services’ keys are their provider plug-ins’,filmopen/fo-openai/openaiandfilmopen/fo-xai/xai(below). A value holding another character no key has, which no header could carry, is not stored. It is encrypted, not hashed, because the app must send it to the platform. Save over a stored key replaces it; Remove deletes that one name. - A stored record’s identity. Each successful Save mints a fresh opaque id from secure random bytes. After the verifier accepts the typed values, Save writes the new id beside the credential values as reserved
_recordId, then writes the matching preference record withKeyState.id. These two writes are Save’s one write: where either throws, Save answers that saving could not be confirmed, logs the failure with its error, and never claims that nothing changed; nothing reads it back or retries it, and the next Save mints another identity. A refused verification leaves the record as it was, and Remove deletes the credential and then the record. Replacing a key creates a new identity even when its last four characters or all its values are unchanged; a pending render collects and cancels with the key stored when it asks (§6.11). The id contains no credential, and the host never sends it or hands it to a plug-in. Restoring display state opens no credential store. The agent build exports the same state throughKeyState.toJson, including its id, while keeping the state in memory. - Where it never goes. It is never written to a project, the log, the preferences or a usage record, and no screen shows a saved key again: only its last four characters, and those only for a value of twelve characters or more. As Save reads the boxes it replaces them with empty ones, whatever the store answers, so that no undo brings a key back; and the log cuts a text box’s value from any error that quotes one (
filmopen_store/app_log.dart). - What the keys section says, in plain words: the key is saved encrypted on this device, in the operating system’s credential store, for this user only; it is never put in a project, the log or the usage log, and FilmOpen never shows it again, only its last four characters; it is sent only to its own platform; and FilmOpen never shares a key, so a user who lets a collaborator use theirs gives it through a password manager, never in a chat or an e-mail, which filmopen.ai will make easier later.
- What the preferences hold (
providerKeys.<owner>/<slot>):status(stored,verified,rejectedorcouldNotCheck, ornone, read from an older record),at(UTC),lastFour,idwhere this build saved a record, andcheck, the code of a check that refused the key or could not tell. The cards are drawn from them alone, so showing them opens no store. With an ephemeral store, as in the agent build, the states stay in memory, so that no preference claims a key the store has forgotten. A state that cannot be read, a value that is not text among them, is No key; a refused preference update is logged askeys.stateNotSavedwith its error and answers that the action could not be confirmed, the card keeping the state it was given for the session. A refused Remove reports that the key could not be removed and asks for Remove again; its recovery sentence never describes Save. Where the key was deleted and only its record could not be cleared, the card says No key and that the key was removed but not recorded — to press Remove once more if it shows again after FilmOpen restarts — never asking for the Remove it disables (keys.removedwithrecord_not_cleared). - The controller (
filmopen_keys/provider_keys.dart,providerKeysProvider) is the only code that opens the store for keys, and only on a gesture. It runs one action per platform at a time, recorded inkeyActivityProvider(saving, checking or removing), from which the cards draw their busy look, so that a card built again while its key is being checked still says so; another platform’s actions proceed independently. - Save is the only door a key goes through (after): one press asks the platform about what the boxes hold and, where the platform accepts it, writes the key and the answer as its state. A key the platform refused, one whose check had no answer, and a value no request could carry are not stored at all, and the card says which. Where the platform file has no
verify, Save writes the key unchecked and the card says that FilmOpen cannot check that platform’s keys. - Nothing re-checks a key already stored: a card whose state came back from a preferences backup says No key until the key is pasted again (§16).
- A Save whose credential write or preference record write throws says inline that saving could not be confirmed and keeps the typed values for Save again. The credential may already have been replaced, so its sentence makes no unchanged-state claim.
- A verifier that throws, though none should, could not check (
verifier_failed), and nothing is saved. - The check (
filmopen_keys/http_key_verifier.dart), which Save makes before it stores anything, sends one request: the platform file’sverify.method, as written, to exactly itsverify.url, over https and only to one of the file’shosts, with the platform’s header filled from the stored values and its other headers, following no redirect, and given up after 15 seconds, its answer’s text read within the same limit. It logs the request — platform, method, URL, the header’s name and the other headers — just before the key goes into its header (keys.verify.request), then the answer’s status, code and text (keys.verify.answer) or a failure’s code and message (keys.verify.failed; a value dart:io refuses in a header by its type alone, since its message quotes the key), and closes the connection. - A 2xx is Verified; a status the platform’s file lists as a refusal is Rejected, with its code kept (
verify.rejected, Project Specification §14.9, read byPlatformVerify.rejected: 401 and 403 where the file lists none, and 400 too on xAI, which answers a wrong key with it and keeps 401 for a request with no credentials — seen on 18 September 2026, where its own pages say 401 — and on Google, whose troubleshooting page shows a wrong key answered 400). The status alone decides, the answer’s text only logged: a 403 is a key the platform knows but a check it refused (a key without the check’s permission, or a request from a country the platform does not serve), and has a sentence of its own. - Anything else is Could not check, with
http_<status>,timeout,connection,bad_value(a value dart:io refuses in a header) orbad_request(a method that is not a token, though the parser leaves such a check out first), and the key stays stored.filmopen_l10n’skeyCheckTextgives every code its sentence, andverifier_unavailableor a code it does not know the generic one. - It runs no model and writes no usage record.
- Which platform files the cards take (
filmopen_format/platform_file.dart;filmopen_keys/provider_catalogue.dart, out of the one reader,filmopen_state’scatalogueProvider). A platform’sauth.kindis read by the word the file spells (PlatformAuthKind.spelling:api_key,oauth,token,none, Project Specification §1.4), never by the enum’s name. A file is unusable when an issue decides where a credential goes: a missing or mistyped field, a base URL over http or off itshosts, a credential id that is not a plain token or repeats one, a key’s header that is notName: valueor holds a placeholder no credential fills, a keychain other thanfilmopen/<platform>, or apaths(Project Specification §14.9) that names a hosthostsdoes not, holds anything but a list of prefixes, names no host at all — an emptypathsis not an absent one, since a person reads it as nowhere — names one host twice whatever its case, or holds a prefix that is not a plain path (pathNotAllowed). A problem with a page’s URL leaves that page out, and a problem withverify— averify.urloutside the file’s ownpathsamong them (pathNotAllowed) — leaves the check out, so that the card says FilmOpen cannot check that platform’s keys and Save stores one unchecked. A platform whose card would repeat another control’s identifier (the idsplatforms,removeandreplace; a credentialsave,removeorguide, andverify, kept reserved though the card has no such button) is left out and logged. A plug-in’s own platform — a key slot on a platform the catalogue does not define, asfo-salad’s SaladCloud — takes its card from the owner’s own file (PluginKeyOwner.platforms,keyCardsOf) on the same terms, a key the person copies and identifiers no other control has; a key’s file is the catalogue’s or its owner’s, never another plug-in’s, and one withoutverifyis checked by its owner’sverifyKey. - The log carries
keys.checked(the check a save makes),keys.storedandkeys.removed, each withplatform,key(the slot’s key id) andcodeonly. Save also leaves its trail, each line withplatform,keyand the milliseconds since it began (ms):keys.save.start;keys.save.startalso names theidof the record it replaces;keys.save.stepwith thestepthat ended —check(accepted, orno_checkwhere nothing can check the key),secret(stored, with the new record’sid) andrecord(written); thenkeys.save.donewith itsresult, only after the record is written, orkeys.save.refusedwith thestepit stopped at (start,input,check,secret,record), a safecodeand theresult— a Save asked for while another action on the card runs is refused atstartwithbusy, after itskeys.save.startand the guard’skeys.busy. An unexpected throw iskeys.save.failedwith the step and the failure’s type and message. A gesture refused because another action on the platform is running iskeys.busy, with the action’s name as its code. Remove leaveskeys.remove.start(the record’sid),keys.remove.step(secretdeleted) andkeys.remove.donewith itsresult, each withms. A credential-store write or delete that throws iskeys.storeFailedwith the failure’s type and message. No line carries a value or any part of one but what a platform’s answer quotes, logged as it came; a verifier that throws iskeys.checkFailed, with the failure’s type and message. Each link opened iskeys.link {url, outcome: start}as it goes to the browser andkeys.link {url, outcome: end, opened, ms}once it answered, and one that throws iskeys.linkFailedfirst, with the address, the failure’s type and message and itsms;keys.stateNotSavednames the key, the status and record id it was writing, and the failure’s type and message; the catalogue’skeys.catalogueIssuea file, a problem and a field. - The web build has no credential store for these keys, since §6.4’s carve-out covers the account session alone. So it keeps no provider keys and makes no paid calls. Its keys section on Settings → General is one sentence saying that keys are kept by the desktop app, and Activity’s Usage says there is no usage on the web.
- A store that cannot be read fails closed.
Plan and local usage. The host records a signed-in OAuth request or a text program as route plan, and an assistant completion on a local server as local. Their key identifies the OAuth slot, program setting or server setting, never token characters. These chains carry reported tokens without an estimate, cost, basis or credit operation, including their started and failed records. A stopped call ends cancelled; no such chain ends with unknown cost. Opening is logged before dispatch and refused if its started record cannot be written. A local assistant opens one chain per model call after its HTTP policy admits the request, including a continuation after tools. A program handover uses its own fresh ledger: a stopped turn has separate cancelled-completion and handover lines. Common status and other free functions open no program chain. The OAuth label comes from the platform block, a local label is the platform’s name, and a program answer’s account.label supplies its account and plan label beside its usage, without another status call. A missing program label is displayed as “on your plan”. The Usage tab shows the route on its third line and leaves both money columns and monetary totals empty for these routes; the tokens are an item of the line’s details instead, beside its date and target, N in · N cached · N out (the record’s input_tokens, cached_tokens and output_tokens, a count it lacks read 0), where the record holds any of the three. Local ctx.ai.complete outside an assistant and local renders do not gain records here.
The usage log. Every paid call the app makes on the user’s own key appends usage records, and every server a person starts for renders a chain of its own: started with its lease’s estimate at the quoted hourly price, succeeded with the minutes it ran and their estimate and its cost unknown, entry compute-session and kind compute — the provider’s bill alone says what it cost (§6.15). A free function on the person’s own key writes no usage record and is only logged: a call of a plug-in’s common status, of key-check’s verifyKey, of a server-provider function (its capabilities, offers, list, deployment, resolving, waking and keep-alive, and stopping) or of lifecycle’s onEnable and onDisable runs no model and is not charged by its platform, so its requests on a key the person stored open no chain (usage_chain.dart’s isFreeFunction, judged on the contract function that runs, never on the plug-in: a render of a plug-in that is also a server provider is recorded). Each key such a call sends is logged once per call frame as usage.key.unrecorded — plug-in, frame, function, key id and last four, platform, route own, decision, reason freeFunction:<function> and the frame’s milliseconds — and the frame matches it to its plugins.http request and answer lines. A call that is over sends nothing more on a key, free or not: a free request admitted after its call ended — a server call cut at its bound while its key was still being read — is refused as a recorded one is, logged usage.key.refused with the same fields, decision refused and reason callEnded. Save’s candidate key records nothing as before; another key a check sends follows the same rule. On FilmOpen credit nothing changes: credit’s admission needs the chain’s operation, so a free function there writes its started record before its request, and with no receipt saying a charge its cost stays unknown. A server’s own compute session is recorded as above (§6.15). Activity’s Usage section lists, filters and totals them on the device, each use saying where its cost comes from and the platform’s own id of the call where a record names one (§9.4). A job brokered through filmopen.ai, later, is recorded by the portal, and the app keeps the portal’s records (Portal Specification P11). The log and the account’s ledger (§6.6) divide the money Activity’s Usage shows, so that no charge is counted twice: this device’s cards show every record of the log — a funded call’s cost is its receipt’s charged cents, so a charge on FilmOpen credit is counted there and nowhere else — and the account’s card shows only the ledger’s lending settlements and earnings that name a lending job, which never enter the log (a lent render and a lending supplier write no usage record); a funded settlement names no job and is left out of the card. The ledger is read live and kept nowhere on the device, and the plug-in’s own price calculation stays evidence, never a cost of its own.
- Where. In the usage folder of §6.4’s table, never in a project. Besides the month files, the folder holds two files:
device.json, shaped{"v": 1, "device": "<a random UUID>", "created": "<RFC 3339 time>"}. It is made under the lock when it does not exist, or when it is read whole and holds no valid id, written beside it and renamed over it so that a crash leaves the old file or the new one; a read that fails is retried, as a write is. It sits outside the preferences so that the id stays with the machine where preferences roam..lock, the file that writers lock.- Files. JSON Lines: one record per line, one file per month (
2026-09.jsonl), chosen by the record’s ownatin UTC. The app never rotates, edits or deletes them. - Writing. Two copies of the app can share the folder (§6.4). A writer:
- takes an exclusive lock on
.lock, waiting up to ten seconds while another program holds the file or its lock; any other failure to open or lock it fails at once, asio; - writes a line feed first, if the month file is not empty and its last byte is not a line feed;
- writes the record and its line feed in one write;
- flushes, and releases the lock.
Windows’ locks are mandatory, which is why the lock is only ever taken on .lock, a file no reader opens: no reader is ever refused a month file. On Linux and macOS the lock is advisory and belongs to the process, so the writes of one copy of the app, whichever log object makes them, are serialised by it as well as against another copy.
A call does not start if its first record cannot be written. A later record of a realtime session, a dictation or a server is retried until it is written (§16), a quarter of a second after a failure, then twice as long each time, up to ten seconds. A plug-in call’s later record is appended once; one that cannot be written is logged usage.notWritten with the record it attempted, its chain, its operation, its cost and the milliseconds, and an ordinary call keeps its answer, since the provider’s work is done and the person has it, while a managed job’s ending fails. The app log’s sink (§6.2) rotates its file, so the usage log does not use it.
- Reading.
- Readers take no lock, and read every month file.
- A line that is not a complete record, such as the end of a file cut short by a crash, is skipped.
- Every version of the record keeps
v,idandrefas version 1 defines them. A record whosevthe reader does not know is skipped with its whole chain, so a reader never counts part of a call it cannot read. - Each skip is reported once in the app log (
usage.badLine,usage.unknownVersion), with the file, the line number and the line’s text. - Fields a reader does not know are ignored.
A record, version 2 — version 1 gave the cost no basis of its own and could not say a cost was unknown; nothing was released, so a reader skips a version 1 record with its chain, as it skips any version it does not know:
| Field | Type | Required | What it holds |
| v | integer | yes | 2 |
| id | string | yes | A random UUID (version 4), in lower case, kept wherever the record goes |
| at | string | yes | When this record was written: UTC, RFC 3339 with milliseconds (2026-09-15T13:02:11.482Z), and later than the chain’s record before it (see Later records) |
| status | string | yes | started, submitted, running, succeeded, failed, canceled or unknown |
| ref | string | after the first | The id of the chain’s first record (see One call’s records) |
| device | string | yes | The id in device.json |
| user | string | no | The filmopen.ai user id, when signed in |
| app | string | yes | The version of the app that wrote this record |
| platform | string | yes | The catalogue’s platform id, such as openrouter, fal or openai, or a plug-in’s own platform’s (salad) |
| route | string | yes | plan for a signed-in slot or program and local for a local assistant server, both token-only; direct, on the user’s copied key; filmopen.ai, on FilmOpen credit — FilmOpen’s own account and the person’s balance, with no key (§6.6). Later, through the portal: filmopen.ai-key, on a key stored on filmopen.ai (Portal Specification P4, P21) |
| entry | string | yes | The catalogue entry’s id (Project Specification §14.7); compute-session for a server’s time (§6.15); conversation and conversation-captions for a voice conversation and its captions (conversationEntry, conversationCaptionsEntry, below); unknown for a plug-in’s request that asked no model — its check, an action, a request of its own — which purpose.plugin names (unknownUsageEntry) |
| variant | string | no | The id of the entry’s variant, when the call used one |
| kind | string | yes | The entry’s kind: text, image, video, tts, voice, music or analysis; compute for a server’s time; conversation for a voice conversation’s model |
| model_id | string | yes | The platform’s name for the model, as the entry’s access row gives it; unknown where the call asked none |
| purpose | object | no | project and target: the file stems of the project and of the entity or shot the call was for. field: the box a dictation filled — a form field’s key, or the identifier of a box outside a form; project is the open project’s stem where the box gave none. plugin: for a plug-in’s call, <plug-in stem>/<hook>, the call a person started — for example status, action.<id>, server.<function>, render, complete, an entity hook, a capability’s function, or dev.<function> from the developer page. Never the prompt or the text |
| request | string | no | The platform’s request, job or session id |
| key | string | on direct, plan and local | The copied key’s last four characters, or the slot/program/server setting identifier |
| label | string | no | The account/plan label for plan, or platform name for local |
| units | object | no | What the platform reports or the app measured, with a field for each of the catalogue’s price units (per). Non-negative integers: input_tokens, output_tokens, cached_tokens, cache_write_tokens, audio_input_tokens, images, videos, characters, requests; cached_tokens are the input tokens read from a cache and cache_write_tokens those written to it, both counted within input_tokens. Decimal strings with no exponent and at most six fractional digits: seconds, minutes, megapixels, credits |
| estimate | object | on started, where anything prices the call | The cost expected before the call, as a money object; absent where nothing prices the call as asked |
| cost | object | on running and succeeded, unless basis is unknown; on failed, canceled and unknown when known | What the call has cost so far, or in all, as a money object |
| basis | string | with a cost, and on a record whose cost is unknown or pending | Where the cost came from: the cost’s own basis — provider, price, estimate or charged — or pending: a call on FilmOpen credit whose receipt had not settled when it ended, with no cost and its operations named, counted as a cost unknown; or unknown: the call cost what nobody says, the record has no cost and holds the units the call used, which are then required, whether or not a reader knows their names. unknown is how a call ended, never on started or submitted |
| operations | array | no | On filmopen.ai, each operation on credit the record’s end accounts for, in the order they ended: id, the UUID the app sent as X-FilmOpen-Operation, which the portal’s ledger and receipt hold as operationId — the shared id by which a use and a ledger row are matched; status, settled or pending; charged_cents, the settled receipt’s charge; receipt, the portal’s id of that receipt (its settlement row); request, the provider’s own id for the request where its answer gave one |
| error | string | no | A short code of the app’s own, such as http_401 or not_found, never the platform’s message; a render’s is its plug-in’s code or the application’s followed by _render, which Usage says as The render did not finish (…). A render that failed after a paid service took one of its requests may have been charged whatever it came to — a request of any of its calls: the job keeps the mark from a pending answer to its last call (RenderPending.charged, RenderJob.charged), and a call on a person’s key in flight when the application closed is marked as it is found interrupted; a stop is never marked — its job keeps its code marked charged. (the format’s chargedFailureMark, chargedFailure, unmarkedFailure; the mark alone where the code is too long to keep with it), and its tile says so (§9.3) |
The voice conversation writes its records under two entry identifiers of its own, without catalogue rows (conversationEntry, conversationCaptionsEntry in the format): conversation, kind conversation, for the conversation model’s mixed speech and text (the binding’s model, gpt-realtime-2.1 or grok-voice-think-fast-2.0, which no catalogue entry prices); conversation-captions, kind analysis (the catalogue’s kind for gpt-live-transcribe, the model it names), for OpenAI input transcription. Each records the exact requested model and the same project, synopsis field and plug-in purpose. These two identifiers are an exception to the catalogue-derived fields above, not catalogue entries or API definitions; the Usage tab names them Conversation and Conversation captions, followed by the box as it names a dictation’s. Records written before these labels, under experimental-voice (kind text) and experimental-voice-transcription (kind stt), read and show as before, by their ids. Usage checkpoints cumulative costs after each unique response, each full minute of input and at known connection closure. These are informational records, with no price-approval gesture.
On credit (no saved OpenAI key, the website’s FilmOpen credit funding the call, §6.11a) the conversation has one chain alone, its route filmopen.ai and no key: the captions have no chain of their own, since the operation’s receipt covers them. Its running records keep the units the provider reported with no cost and the unknown basis, never a local estimate written as money spent. Its terminal record’s cost is the receipt’s chargedCents in US dollars (charged), with the operation on its operations, and pending where the receipt read came back unsettled; unknown where no read was made. The own key’s accounting is unchanged.
During Finish’s drain (§6.11) a response.created and a response.done are still accounted, so a response begun or ended during the drain is counted though its output is dropped — and one never reported keeps the total unknown —, and the records of a session one of whose releases failed are written as before. The cap (§6.11) ends a conversation through Finish, so its terminal record is written once as any Finish’s is: the conversation’s when the provider socket has closed, the captions’ at End, both after End has awaited every release — the microphone’s and the player’s included — since it begins them together and awaits them all before the terminal records.
On the person’s own key voice costs use basis estimate, calculated with exact decimal arithmetic at the published provider rates. OpenAI response pricing requires reconciled text/audio input and output counts and cached-input counts; missing or inconsistent reports, and an expected response not yet reported, keep the conversation’s total unknown. Generated audio is priced even when playback is interrupted. Grok server-VAD pricing uses locally measured connected-session seconds, including idle time, plus the initial text context item; microphone cleanup after socket closure adds no connected time. OpenAI gpt-live-transcribe has its own chain, independent of conversation tokens. With WebRTC, no locally sent PCM duration is available: the chain retains provider-reported units and an unknown cost. It does not invent zero input seconds or caption minutes. Reported supported token units are also retained. Local duration is explicitly an estimate rather than a provider measurement. Session totals count each chain once; duplicate events cannot charge it twice. No calculated amount is presented as a provider-confirmed invoice, and older unknown records are not retroactively guessed. On credit the receipt is the only money (above).
A money object has four fields:
amount: a decimal string with exactly twelve fractional digits ("0.042500000000"), with no sign, exponent, comma or space;currency: the platform’s billing currency,"USD"for every first-version platform;scale:12;basis:provider(the platform reported the cost),price(units at the catalogue’s price),estimate, orcharged(the filmopen.ai portal’s settled receipt: what FilmOpen credit was charged, §6.6; never a plug-in’s word).
A money object holds and calculates its amount as an exact decimal (ExactDecimal), never as a binary floating-point number; a render’s quote and a record’s amounts are such objects. Not every figure starts as one: some displays still read a double before the exact decimal — for example the GPU page’s running cost, a batch’s detail and a media item’s provenance, the last two from the batch’s cost, which the format reads as a double — and a server’s hourly offer is a double, from which a server’s usage estimate is made.
- Reading numbers. Catalogue prices and a platform’s reported costs are JSON numbers, and both are read as exact decimals from the number’s text.
- Rounding. An amount computed from a price with more than twelve fractional digits is rounded half away from zero to twelve. A platform’s cost with more than twelve fractional digits is not recorded as
provider; the record usespriceinstead. - Why rounding is allowed. These records are for display and never settle money (Portal Specification P11).
- The cost of a call is the cost the platform reports for it, where it reports one (
basisprovider). Otherwise it is the catalogue’s price times the units (basisprice): the units the platform reports, once they are checked to be the units the catalogue prices, or else the units the app measured, such as the minutes of audio it sent (§16). Where an access row’s token prices name OpenAI’s long-context band (input above 272K tokens), the band is chosen by the whole input — above 272,000 tokens — before cached reads and cache writes are taken out, and input, cached input, cache writes (cache writes) and output are each priced at that band, as the portal prices them; Batch, Flex and Fast rows are never chosen, and a row naming any other condition prices nothing that way. No estimate stands in for a reported answer. - On FilmOpen credit (route
filmopen.ai) the cost is the portal’s settled charge (basischarged), per operation. An ordinary assistant completion writes its pending Usage with its operation id before another call starts. Its receipt is read in the background; settlement appends a charged successor in the same chain, whose latest record alone counts in Usage and totals. A failed read, or an app closed before settlement arrives, leaves pending: not yet settled by filmopen.ai. No ordinary-text restart receipt reader runs. Each model call owns its own ledger and operation UUID. Render, dictation and voice settlement retain their own rules.credit.receipt.backgroundlogs pending persistence, receipt outcome, settlement persistence and elapsed time; a failed append logs the operation, record and error without claiming settlement was written.
One call’s records.
started. Every call on the user’s own key writesstarted, with itsestimate, before anything is sent. An estimate is the catalogue’s price for the call as asked:- a text call’s input tokens and its maximum output tokens;
- an image, a video or a piece of music as requested;
- a voice line’s characters;
- a recording’s transcription, by the recording’s length;
- a live session’s first minute.
Where the request does not bound what the call can cost, the estimate is the smallest amount it can cost (§16). Where nothing prices the call as asked — a model the catalogue gives no price for, such as a voice design, a function with no model, a unit the request does not bound — started carries no estimate: a cost nobody can foresee is never an estimate of nothing.
submitted. A queued job, meaning a request answered with a job id as fal’s queue is, writessubmittedwith the platform’srequestonce the platform accepts it.running. A live session writesrunningafter each full minute of audio sent, with itsunitsandcostso far.- The final record (
succeeded,failed,canceledorunknown) is written once the call’s end is known. A managed render also records each answer’s reported cost, basis and safe failure code before its provider end is understood. Such an answer records the outcome of that call, not completion of the provider’s accepted work: uncertainty permits collection on the same ledger until an understood end or the manager’s terminal settlement, including Dismiss, closes it. Nonclosing answer costs are appended immediately to the existing log; a confirmed terminal effect is prepared and journaled before its append (§6.15). - A request or a queued job ends when the platform says how: its answer, the job’s final status, or an error returned before it accepted the call. A connection that fails before the request is sent (no network, a name that does not resolve, a failed TLS handshake) ends it
failed, with no cost. A local cancel or a timeout ends nothing, and the chain stays open. - A live session ends when its connection closes, whatever closed it: the user closing the popup, the platform’s time limit, or a dropped connection. It ends
succeededwith the minutes sent once the platform accepted it;canceled, with no cost, when the user closed the popup before the platform accepted it, since nothing was sent; orfailed, with no cost, if the connection never opened or the platform refused the session before it started. While the connection stays open, pausing and resuming the audio (§9.6) continue the same chain. unknownis written only when the platform answers that the request does not exist.- Later records. Every record after a chain’s first names that first record in
ref, and repeats the fields of the chain’s record before it unless it gives new values.v,id,at,status,ref,appanderrorare always its own. Itsatis later than that of the chain’s latest record the writer has read, by a millisecond if the clock has not moved on or has gone back. - A correction is a later record with the corrected values and the status of the chain’s counted record, so that correcting a finished call never reopens it.
- A managed render’s answer history. Each cost-bearing answer has an immutable stable id in the original chain, derived from its ordinal and distinct from the final disposition’s. Retrying a written answer or a journaled effect preserves that id and its exact line, and an answer whose id the usage log already holds is not appended again; an append that throws after its line landed is logged
usage.answerAppendFailedwith the error, its stage, the re-read’s result and the reason the line is kept. Reported totals for the same verified receipt replace that receipt’s prior total; distinct receipts and uncited answer episodes add once. Nested answers are consumed once by the answering call. The final counted record expresses the cumulative cost and final disposition, while earlier raw costs, bases and safe reasons remain in the log. Each managed chain keeps a distinct stable final disposition even when its cost equals the last raw answer; a repeated preparation reuses the same exact line. A resume takes up only the saved chains the ledger does not hold, so a valid older snapshot does not replace live history; an entry that cannot be read is passed over and logged (usage.resumeSkipped). Restoration rebuilds each resumed chain’s answers and billing from its saved answers and the usage log, matched by stable id, before another request can send, and refuses on a reading that is not complete. An already prepared outputReady payload replays its exact local effects after restart without collecting or submitting again, including an append that landed before its acknowledgement failed. - Which record counts. A chain is a first record, which has no
ref, and every record whoserefnames it. Records whose first record is missing still form one chain, by therefthey share. The chain’s counted record is the one with the latestat; on a tie, the greaterid.
A cost nobody knows is never a cost of nothing. A call a platform charged for without saying how much — a plug-in’s paid request whose answer it then refused, a service that reports credits and no money — ends with basis unknown, its units and no cost; a later record that learns the cost gives it with its own basis.
Totals use each chain’s counted record, and count the chain once, in the month of its first record (or of its earliest record, when the first is missing):
| Status | Counts as |
| succeeded, running | Its cost is spent |
| failed, canceled | Its cost is spent when the platform charged; with no cost, nothing |
| started, submitted | Its estimate is pending, or possibly charged; with no estimate, cost unknown, as below |
| unknown | Its cost is spent when known; otherwise its estimate is possibly charged |
| any, with basis unknown or pending | Cost unknown, counted apart as a number of calls, on the month’s line for the platform — the one in the estimate’s currency where there is one, else the first — and never as money or as free |
After start-up, and only if the usage folder holds a chain whose counted record is submitted, the app opens the credential store and asks each platform about every such chain whose key matches the key now stored for that platform. Nothing else at start opens the store (§6.6).
- It writes the final record once the platform gives one.
- It writes nothing while the job is still queued or running, or while the platform cannot be reached or refuses the key.
- Chains submitted with a key since replaced stay open (§16).
- It never resubmits a call, and writes nothing for a
startedorrunningchain.