Skip to main content

DartWay 1.0 — decisions taken during the build

The owner delegated every unclear or contested point to the implementer on 2026-09-13 ("do them at your discretion and record them"). Each entry is one decision, why, and whether it waited for the owner's review during the build. Newest last.

#DecisionWhyReview
D-001One WebSocket per client carries requests, commands, subscriptions and updates; HTTP only for external doors.Owner: "DTO and WebSocket are the only mechanism". One authentication per connection, no per-call HTTP overhead.no
D-002JSON on the wire. DTOs carry a type tag only where the receiver cannot know the type statically (updates, command results); request results and nested DTOs are untagged.Debuggable, zero dependencies; tags cost bytes only where they carry information. A binary codec can replace JSON behind DwProtocol later.yes
D-003DateTime travels as UTC microseconds, Duration as microseconds.Exact and compact; no time-zone text to misparse.no
D-004Stack: relic 2.0.0-rc.1, postgres 3.5.12, analyzer 14 (generator only). Pitfalls recorded during the build.relic 2.0 is not final; 1.x would mean a migration soon.yes
D-005Request kinds: DwSingleRequest (absent ⇒ dw.notFound refusal), DwMaybeRequest (absent is a value), DwListRequest, DwPageRequest (offset), DwCursorRequest (before id). Page size is a getter of the request class; the server reads it from the same class, the client cannot ask for more.Exactly the shapes used by U90/Molodey (model, maybeModel, lists, offset and cursor pagination). A caller-chosen limit is a way to ask the server for everything.yes
D-006A command result is one value (DTO, primitive or null); collections are wrapped in a DTO.Decoding a generic List<T> from an erased type parameter needs per-type code; no active project needs a list result without a wrapper.no
D-007The framework owns accounts and identities (dw_account, dw_identity, keys, code tickets); the project's profile table references the account id. The auth DTOs live in dartway_core."The framework knows no domain": it knows that someone signed in, not who they are to the project. Molodey needed two identifiers per account and got them by hooks; a separate identity table makes that the default.yes
D-008Enums are stored as text (their name), never as native Postgres enum types.postgres 3.5 decodes custom enum types as raw bytes; text needs no migration to add a value.no
D-009dartway_generator is not a workspace member.It pins analyzer 14; dartway_lints (custom_lint) pins analyzer 8.no
D-010Removed from the branch until ported: dartway_push_*, dartway_studio_binding. The offline family is postponed (owner).They depend on the Serverpod core; the first goal is example/ running. Push is needed by U90 and is ported right after.no
D-011No joins or includes in the 1.0 ORM: related rows load with findByIds, one query per relation.Typed joins are the largest part of any ORM; batch loading has no N+1 and covers every include in active projects (max depth 2).yes
D-012Entities are annotated Dart classes in the server package, not YAML.One language; the analyzer checks declarations; agents read Dart better than a bespoke format.no
D-013Command outcomes (ok and refused) are kept 7 days for idempotency; failures are not recorded.A refused intent answers the same on retry; a failure must be retryable.no
D-014The server runs a single isolate; scale-out is processes plus LISTEN/NOTIFY later.relic's multi-isolate mode silently duplicates in-memory subscription state.no
D-015Validation: a DTO implementing DwValidatable runs validate() on the client before sending and on the server before the handler.Owner wanted validation and permission refusals unified and not duplicated.no
D-016Job payloads are JSON maps.Server-only DTOs would need a second registry for no gain.no
D-017First milestone (example runs) does not include file uploads, push or the editable string catalogue; they follow in the next milestones in that order after push.Scope of the first goal set by the owner; each is a separate subsystem.no
D-018Updates are delivered to the author's connection too (reverses the original no-echo rule).Found while building the client: a command publishes to channels the client cannot know (booking a session also updates the schedule), so without the echo the author's own screens stay stale while their other devices update. The duplicate costs one update message to a connection that is subscribed and asked for it; the client applies updates idempotently.yes
D-019The generator is a dev dependency of the project's server package and is run by dartway generate; the CLI does not link it.Analyzer version pin (D-009); the generator must match the project's core and ORM versions.no
D-020Every channel subscription requires a signed-in connection.No active project has anonymous realtime; an anonymous subscription is a way to hold server memory without an account.no
D-021Sessions (keys) do not expire; they are revoked (sign-out, admin).Mobile apps expect to stay signed in; expiry without refresh tokens signs people out at random.yes
D-022DwAccounts over a bare database (no running server) refuses publish, revoke and jobs loudly instead of dropping them.A silently dropped update is exactly the silent-failure class 1.0 exists to remove; tools without a server (seeds) create rows without publishing, explicitly.no
D-023A single request's handler refuses dw.notFound; every registered request and command must have a handler or the server does not start; schema given to the server fails startup when a declared table or column is missing.Declarations are checked where they are made, not discovered by the first user who hits them.no
D-024The example's first milestone drops Studio binding wiring and avatar upload (their packages are not ported yet).D-010, D-017. They return with the binding and uploads.no
D-025Revision 2 (owner review 2026-09-14): HTTP per DTO + WebSocket for updates (reverses D-001); ApiResponse with transport, author gets own updates in the response (reverses D-018); naming …Row / two-word names; explicit update actions with named constructors; DwTableRequest, DwWindowRequest replacing DwCursorRequest; honest HTTP statuses; protocol and app version headers with minAppBuild; three hosts with proxied /dw/.Owner decisions.no
D-026Dw-Live-Connection header links an HTTP command to the caller's WebSocket, so its updates are filtered to that connection's subscriptions and not echoed on the socket.The owner chose "only what the author listens to" in the response; an HTTP call has no connection of its own, so the client names it.no
D-027Undiscussed tails decided by the implementer: app_site/ optional project folder served by deploy; sessions never expire, only revoked; verifying a code creates the account; Riverpod stays the public state API; relic stays a dependency hidden behind framework HTTP types (fork on the first needed internal change); DwDeleted unchanged pending its own discussion.Owner: "start"; delegation of 2026-09-13.yes
D-028relic is removed; the server runs directly on dart:io behind DwHttpRequest/DwHttpResponse. Supersedes D-004 and the relic part of D-027.Owner, 2026-09-14, after the HTTP-per-DTO decision: every app call is now HTTP, so a general-purpose layer's per-request overhead matters (frameworks measure 10–30% below raw dart:io); relic's strengths (typed headers, big routers, middleware, multi-isolate) are unused, its WebSocket was already bypassed, and it is an rc dependency of ~15.7k lines tied to Serverpod's release cycle. The replacement is a few hundred lines of our own.no
D-029Stage 1 of revision 2 settled: a table request's page/pageSize travel in the body as fields (they are its key), not in the query; page and window requests accept a pageSize query parameter clamped to maxPageSize; window cursors replace the flags on the wire (hasOlder/hasNewer derived); DwMaybeRequest.matches is required (no safe default); acceptsDeletion so unrelated deletions never reach a request; incompatibility is two refusal codes (dw.protocolUnsupported, dw.updateRequired); the HTTP status is a pure function of the ApiResponse body (DwFailure distinguishes 500/400/404); DwServerCall is the sealed base of requests and commands; DwTableRow the base of row classes.Found while implementing R2 in core.no
D-030Package names: dartway_core_shared (pure Dart, for every side), dartway_core_server and dartway_core_flutter re-export it; dartway_orm and dartway_client are internal packages re-exported by them; dartway_generator is a dev dependency of the server package. Renamed after stage 2 finishes.Owner, 2026-09-14.no
D-031Package versions stay 0.x for at least a month, until two projects and Studio run on the rewrite; the zero-major licence (promise nothing, preserve nothing) keeps applying. "1.0" names the rewrite, not the version. Nothing existing is preserved in Molodey/U90 either: no compatibility layers, databases recreated.Owner, 2026-09-14.no
D-032Satellites (router, studio bridge, lints, shared_preferences, telegram, cli) version independently; the core family raises its caret on a satellite only in its own next minor; a project that needs a newer satellite earlier uses dependency_overrides, and dartway check warns when such an override outlives the framework's own raise.Owner's compromise, 2026-09-14 ("all options are imperfect").no
D-033Order until the Studio demo of Molodey on 2026-09-21: finish revision 2 and the example → Molodey on the rewrite together with the template and file uploads (fresh database) → deploy (three hosts, proxied /dw/) → Studio binding by the Studio vision-2 spec → after the demo: push, editable strings, toolkit and docs, U90.Owner, 2026-09-14.no
D-034File uploads: the client uploads directly to S3-compatible storage by a presigned PUT; the server names every object (purpose/<account>/<random>.<ext>), verifies size and type by HEAD on finish, and records dw_stored_file; purposes are a project enum in shared with a server rule each (public/private, max size, content types, who may upload); private files are read through short presigned links behind a project hook; a framework job removes unconfirmed uploads; SigV4 is implemented in the framework (no S3 SDK); projects store file ids on their rows and resolve URLs in batch.Moved before deploy for Molodey (avatars) by the 21.09 order; closes the 0.x history: client-named keys (#149, #191), everything public-read (#213), piling reservations (#215), missing object as a crash (#224).no
D-035Uploads as built: the presigned PUT signs exact length, content type and if-none-match: * (no overwrite, even through the same ticket; a 412 on retry means "already arrived"); finish locks the row and checks expiry after the lock (race-free with cleanup); upload refusals are their own enum DwUploadRefusal; DwGetFileLink is a request (no signed URL stored for idempotency); per-account maxPendingUploads; deleting a file enqueues object deletion in the caller's transaction; dw_stored_file does not cascade from accounts. Gaps: no browser upload test (bucket CORS documented), no multipart, no cancel.Found while building D-034.no
D-036Updates carry their channel; an object applies only to state entries listening to that channel (routing by type stays, within the channel). Refines the owner's "route by type": found in the example — without the channel an admin's answer carries another member's UserProfile and a client cannot tell it from its own. Response transport is grouped by channel then type.Correctness; the channel is the only fact that says whose data an object is.yes
D-037DwLiveChannel.ofCaller(kind): a channel keyed by the caller's account, resolved by the client when subscribing and checked by the server rule; "my" requests need no id. Table requests refetch (coalesced) on a new matching object so the total stays true.Removes the accountId workaround in "my" requests and the example's table hack.no
D-038As built for D-036/D-037 and the port fixes: one channel's type groups are DwChannelUpdates (the socket upd body), the response transport maps channel → DwChannelUpdates; a request without channels hears no updates; publishing an unresolved caller channel on the server throws rather than meaning "the caller"; a table page treats any object not on it as possibly new and re-reads (the client cannot tell new from another page); the same-origin check compares Origin host and port with Host (which carries no scheme); dartway_orm depends on dart_style (^3.1.3, spanning the workspace's analyzer 8 and a project's 14) to format drafts.Found while implementing D-036/D-037 and the smaller fixes of the example port.no
D-038bTwo buckets: public (anonymous object read, no listing, publicBaseUrl) and private (no policy; signed links after canRead); a rule's visibility picks the bucket; startup probes both (signed HEAD, anonymous read allowed on public and refused on private, neither listable) and refuses to start on any mismatch; DwFileStorageSetup.provision for dev/tests/deploy. In the MinIO deploy the startup probe is off (DW_STORAGE_VERIFY_BUCKETS=false) because the server reaches MinIO only through the proxy that starts after it; the deploy's outside probe performs the same check once the stack is up.Owner, 2026-09-14: "implement a private bucket, it works nowhere now".yes
D-039No CORS, also in development: local web development runs same-origin through dartway dev — a CLI proxy that serves the Flutter web dev server and forwards /dw/* (including the live socket) and /health to the local server, mirroring the production nginx layout.Keeps one topology from laptop to production (D-025, docs/5-tooling/deploy.md); found independently by the example run, the Molodey port (tool/web_proxy.dart) and Studio (bin/dev_web.dart) — three copies of the same proxy.no
D-040Generated table definitions use member names a row field will not want: tableName, tableColumns, tableSchema (was name, columns, schema), and the row class's static is tableDef (was table), so name, schema, columns, table, row are legal row fields.Studio: name is the most common field and was forbidden.no
D-041A field with a constructor default may be absent on the wire: the decoder applies the default, and the encoder omits a value equal to its const default. Required fields without a default stay required.Studio/MCP: raw calls without optional fields failed as malformed; and "nothing redundant on the wire".no
D-042Session keys have a kind and a label. dw_auth_key.kind is app (made by a sign-in) or personal (made by DwAccountService.issueKey); label is for people. An app key's label is what the app said about itself — Dw-App-Version and User-Agent joined by ·, control characters folded, cut to 200 characters (DwSessionKeyInfo.maxLabelLength); a personal key's label is required, trimmed, and refused (ArgumentError) when empty, longer or holding control characters. ctx.sessionKey (DwSessionKeyInfo?) is the server's record of the key that authenticated the call — on HTTP calls, live subscription checks, and routes declared with DwRouteAuth.optional/required (401 with WWW-Authenticate: Bearer for a missing-when-required, unknown or revoked token, 400 for a malformed header).Studio tells a Claude Code key from the app without a client-provided field, and minted and revoked keys with raw SQL; only the kind is trusted — the label comes from the client and describes, never identifies. Routes opt in because a webhook's Authorization belongs to its sender.yes
D-043A call that mints a token never stores its successful outcome. issueKey marks its context; a command whose context made a secret skips the idempotency record (refusals are still recorded). A retried send runs again and makes a second key; the first, whose token nobody received, is listed and revocable.The alternative stored the bearer token in dw_command_outcome for 7 days in plain text — exactly what Studio's port did. Automatic rather than a handler flag, so a project cannot forget it.no
D-044Revocation of one key (revokeKey(keyId, {accountId})) is immediate in the process that commits it: the token cache forgets the key and refuses to re-cache it, and every live connection on the key loses its subscriptions and is told rejected — the sign-out path. Another process notices within DwServerSettings.tokenCacheTtl; so does the running server when the revocation is written by DwAccountService over a bare database. accountId restricts the revocation to that account's key (for key ids that came from a client). Revoked keys stay listed until dw.cleanup removes them a day after revocation.Single isolate per process (D-014); cross-process push of revocations waits for LISTEN/NOTIFY scale-out.no
D-045Attaching or changing an identifier by code is two built-in signed-in commands over the sign-in ticket table: DwRequestIdentifierCode (same normalization, per-identifier limits counted across both purposes, fixedCode, deliverCode with ctx.accountId = the attaching caller) and DwConfirmIdentifier(ticketId, code, {replace}). A ticket carries its purpose and, for an attach, its account: a ticket of another purpose or account reads as expired and burns no attempts. An identifier of another account is refused only after the right code (DwAuthRefusal.identifierTaken, field code; the ticket is used), so the request answers the same for a free and a taken identifier — what sign-in reveals (isNewAccount) to someone who receives the codes, and no more. An account may hold several identifiers of a kind; replace gives the oldest of the kind the new value in place (its id stays) and removes the others.Molodey needed a second identifier and a phone change without a second sign-in; a refusal at request time would make the command an account-existence oracle for any signed-in user.yes
D-046DwAuthConfig.onIdentifierChanged(ctx, DwIdentifierChange) runs in the changing transaction for every identifier change the framework makes to an existing account — confirm (attach, replace, removal of the replaced kind's others), moveIdentities (two changes: removed from one account, attached to the other) and removeIdentities — with previous/current and a DwIdentifierChangeCause. Not for the identity an account is created with (onAccountCreated sees it), nor for a sign-in re-verifying an identifier. A throw (or refusal) rolls the change back; for a confirmation the ticket stays usable. The framework publishes nothing about identifiers.A project mirroring an identifier into its rows (the example's UserProfile.phone) would drift on any change it is not told about; one hook for all causes keeps it one rule.no
D-047onAccountCreated receives DwAccountOrigin: DwSignInOrigin(registration) or DwToolOrigin() (DwAccountService.ensure), replacing the registration map in which "empty" meant "a tool".Molodey: a sign-up that sent no fields and an admin bootstrap were indistinguishable.no
D-048dw_identity.verified_at is when a code sent to the identifier was last confirmed for the account: set by every sign-in and confirmation, NULL for identities ensure made until they sign in. The migration leaves existing rows NULL — which of them were ever verified cannot be known.DwIdentityInfo.verifiedAt for "confirmed" badges and re-verification rules.no
D-049DwAccountService covers every read and write a project made on the framework's tables: listIdentities, listIdentitiesOf (batch, every asked account a key), accountsMatching(fragment, {kinds}) (case-insensitive substring, LIKE characters literal, empty refused), moveIdentities, removeIdentities, issueKey, listKeys, revokeKey, revokeKeys. Moves and removals take the per-identifier advisory locks in sorted order, then re-read the rows under them."No raw SQL on dw_* in projects": Molodey's MolodeyIdentities also batch-read, searched and dropped identifiers, beyond the list and move asked for.no
D-050Framework migration 20260914_220000_dw_keys_and_identities: dw_auth_key.kind (app/personal, check) and label; dw_identity.verified_at; dw_code_ticket.purpose (signIn/attach, check) and account_id (FK, cascade; required exactly for attach). The defaults that fill existing rows are dropped in the same migration, so a write that forgets a column fails.Appended to the chain; nothing applied is rewritten.no
D-051Template on 1.0: sign-in by phone or e-mail code with one required terms consent and optional marketing (consentsRequired refusal, the Molodey pattern); signUpClosed and ownRoleLocked refusals; APP_BOOTSTRAP_ADMIN names the first admin; a reviewer/seed fixed code on the profile; an international phone field instead of the Russian mask; dartway test starts MinIO beside Postgres (--no-storage); dartway create --framework-path builds against a local checkout until the family is published. Check rules for CRUD configs and generated formatting are removed; generatedCodeStale and migrationsDrift replace them.Ported from the example and the Molodey port.no
D-052A change of the framework's wire format is a protocol change: from the first release on, any change to how calls, ApiResponse, update transports, live messages or generated DTO JSON look on the wire bumps dwProtocolVersion, so an old app answers 426 and shows "update the app" instead of failing to decode. Enforced by a golden test of canonical wire encodings that records the protocol version it was taken at. D-041 (defaults omitted) changed the wire without a bump; harmless only because nothing on 1.0 is released.Owner, 2026-09-15.no
D-053A command's response carries the publications its caller may read — the channels its named live connection subscribes to, and every other published channel whose rule (canSubscribe) allows the caller, asked after commit in one shared context; not a channel the command revoked for the caller; nothing for an anonymous caller (rules run for accounts, D-020) or once the command revoked the caller's own key. A throwing rule is reported and reads as "no". The socket broadcast happens synchronously at commit, before any rule is awaited, and leaves out the named connection. Publishing to a kind without a rule, or to a channel its rule would not resolve, throws at ctx.publish. DwFakeServer answers the same by its subscriptionRule. Refines D-026. Replaces the first form of this decision (same day, ca7c953): a response carried only the named connection's subscriptions, and nothing without a socket.The response used to carry every publication, which leaked channels the caller may not read: the example's newcomer sign-in received the admin-only counters its onAccountCreated hook published. Tying the response to the socket closed the leak but made a command's own result depend on having a socket; the owner: the caller gets everything it is entitled to automatically, and indirect effects (telling the team of a new sign-up) never reach it — the boundary is read access, not the transport. The rule is the one access statement a project already writes, so no second API marks "side effects". Cost: one rule call per published channel the named connection does not already subscribe to, memoised through ctx.memo. Duty it puts on projects: a channel rule must be true for any caller, not only for the screen that subscribes. The broadcast is not delayed by the checks: an awaited gap would let a later call's publication overtake an earlier one on the same channel.no
D-054Server modules: DwServerModule in DwAppServer(modules:) brings its own migrations namespace, handlers, jobs (dw.<ns>.…), startup checks and close(); a handler reaches it by ctx.module<M>() (no globals). Push is the first module. Gap: a module has no access to the alert sink yet.Replaces Serverpod modules without their generated-code and migration-bootstrap failures (#110).no
D-055Push: recipients are account ids; devices are bound to the session key, so revoking a key stops pushes without a client call; the eligibility hook is batched (ctx, notice, accountIds) → Map<int, DwPushDecision> (a per-recipient hook is how the old worker reached 13k queries); delivery claims with SKIP LOCKED in a short transaction, calls providers with no connection held, records with a lease check; invalid tokens remove only that transport's device; the typed payload travels as dw_type/dw_payload/dw_link written by one shared class. Remaining duplicate window: a crash between provider acceptance and the record re-sends to that device after the lease expires.Ported from the 0.x module with the audit's lessons (#54, #78, #85, #205, #110).no
D-056A framework migration that cannot apply is corrected in place, and the ledger accepts the text it replaces. 20260914_180000_dw_stored_file_bucket added dw_stored_file.bucket NOT NULL without a value and failed on every database holding a file. It now adds the column nullable, stops with the statements to run when rows have no bucket (ALTER TABLE dw_stored_file ADD COLUMN bucket text; UPDATE dw_stored_file SET bucket = '<bucket>'), then sets NOT NULL. No backfill: those rows were uploaded to the single DW_STORAGE_BUCKET of the time, which is neither of today's buckets (Molodey's molodey became molodey-public), and a guessed name sends links and deletions where the object is not — a delete of a missing key succeeds silently. DwDatabaseMigration.supersededChecksums lets databases that applied the first text (on an empty table, with the identical result: Studio's and club_demo) keep their ledger row.A new migration could not help: the failing one runs first, and rewriting its checksum alone would stop every database that applied it. Chosen over a server setting naming the old bucket, which would be a compatibility shim living forever for a few development databases.yes
D-057The CLI's own revision is the default source of the template and the toolkit. Without a named checkout (--local-repo, --framework-path, DARTWAY_MONOREPO_DIR) or a chosen channel (--channel, DARTWAY_BRANCH, a channel recorded by the project for update), create/setup-ai/update use the monorepo the running dartway_cli package resolves into — found through the package configuration (Isolate.resolvePackageUriSync), so a path or git activation finds it and pub.dev's unpacked archive does not; only then stable. A project that recorded a channel is refused rather than moved onto that checkout by a plain setup-ai.The rewrite's CLI cloned stable and handed out the 0.x template, and nothing about it looked wrong. A default branch constant (dartway-1.0) was rejected: it would have to be flipped back at the release, and the branch then deleted would break every CLI still carrying it. Pinning a pub.dev-installed CLI to its revision needs a published ref per version and is left to the owner.yes
D-058The last seven one-word public names are renamed, closing the debt rule 8 of docs/DESIGN.md declared: DwConfig → DwFlutterConfig, DwFlutter → DwFlutterToolbox, DwPlugin → DwFlutterPlugin, DwPlugins → DwPluginRegistry, DwFeature → DwFeatureWidget (Flutter core), DwRoute → DwHttpRoute (server), DwRouter → DwAppRouter (router). Names only — no signature, behaviour or wire change — with a migration note. The rule's paragraph in DESIGN.md now states that a one-word name is a defect, not a debt: there is no list left to add to.The rename table of the naming rule (docs/DESIGN.md §8, owner, 2026-09-14) covered the names the rewrite touched; these seven were listed as debt and would otherwise be settled by whoever tripped over them first, one at a time, each with its own migration note for projects to answer. Done now because every project on 1.0 is still being ported — Studio and Molodey rename once, in the port they are already doing, instead of twice.no
D-059A pending migration whose source changed after sealing is refused where it would be applied, when the source is on disk: DwMigrationRunner(sources: {namespace: directory}), passed by migrate apply and by DwAppServer(migrationsDirectory:) when that directory exists. Refused as a DwMigrationRefused problem (DwUnsealedMigration) before anything runs, naming the file and rehash <id>. A compiled server has no sources and checks nothing; migrate check and dartway check keep reporting it in CI.Applying an unsealed migration ran the new text and recorded the old checksum; the rehash the author then ran made every later start of that database refuse the migration as edited after it was applied — a false refusal, fixable only by writing to the ledger. The skill told authors to keep the order; the runner could see it.no
D-060Pending migrations apply by namespace — dw, the modules in declared order, then app — then by id, after dependsOn. Namespace order is the order of the runner's migrations map (DwAppServer: dw, modules, app; the migration CLI: its modules, then its own namespace). Replaces ordering by id across namespaces (docs/4-server/migrations.md until now).The framework never references a project's tables; a project references the framework's and its modules' all the time. By id that held only while each project migration was newer than what it relied on — and the template's initial migration (20260914_204255) is older than the framework's 20260914_220000_dw_keys_and_identities and the push module's migrations, so every project created from it started out relying on luck. DwServerModule's documentation and the startup test's name already said "after dw, before app" while the code and the test body interleaved.no
D-061The template and the example obey rule 1.3a — no top-level functions — rather than the rule giving way to them. Their functions become static members of classes named for what they are (DartwayStarterServer.build, AppAuth, AppBootstrap, AppChannels, AppPublications, AppFiles, AppDwCore; ExampleServer, ExampleAuth, ExampleChannels, ExampleFiles, ExamplePush, ChatAttachments, ChatMessageMenu, ExampleDwCore), extensions on the type they work on (AppRefusalText on AppLocalizations, AppWipAction on DwFlutterCore, AdminCounting and private helpers on DwCallContext, filters on the list request, ChatFileSize on int), or a local function of main. The rule gains its third, already practised exception: test helpers.The skill forbade what the skeleton every new project copies did in thirteen places and the example in seventeen, so an agent following the skeleton broke the law and one following the law contradicted the skeleton. Of the two the rule has the reason (a function found through the type it works on); the skeleton had only habit. The existing AppObjects.card already had the shape. Projects are not touched: their code is theirs, and no framework API changed.no
D-062Server code reads and writes files on its own authority: DwFileService.read(fileId) (bytes, capped at the recorded size), readLink(fileId, {expires}) (a presigned GET, the storage's link lifetime by default, at most seven days) and store(purpose, accountId:, bytes:, contentType:, fileName:). No canRead/canUpload: the caller is the server. store holds the purpose's size and types as ArgumentErrors, writes with if-none-match: *, commits the row unconfirmed on the pool before the object exists and confirms it in the caller's transaction — a rollback leaves an unfinished file for the existing cleanup.U90, first project with private health photos: the AI analysis of a body photo and generated images had no way through, and the only workaround was making the photos public. A file row committed before the object, rather than after, is what makes every failure between the two cleanable by the machinery that already exists.no
D-063A push image that is not https is left out of the notification, with a warning, rather than failing the send. DwPushMessage.problemIn refuses only an image that is not an http or https URL; DwPushService.send logs push image … is not https when queuing one over http; the worker passes providers the image only when DwPushMessage.showsImage holds.U90: a comment rolled back because its notification's picture was http. The picture comes from data — a storage's public URL, http on every local MinIO — not from code, so failing fast punished the environment instead of a mistake, and made every acceptance test with a pictured push fail locally. The picture is decoration; the command and the notification's words are not. The warning keeps it from being silent in production.no
D-064A row column may be a List<E> of an enum, stored as a jsonb array of names (DwEnumListType), elements non-null. The same choice as a single enum (D-008): names, so a new value needs no migration; an unknown name fails the read. Not text[]: every other list column is jsonb, and one list shape keeps insertAll's array encoding and the schema comparison uniform.U90 had four such fields and worked around the refusal with List<String> and a typed getter — a second representation of the same value, which the row's equality and copyWith would not guard.no
D-065DwAppServer.runInContext(work, {scope}) runs server-level work in a background context with no caller, in one transaction, delivering publications and revocations after commit and nothing on a throw — what a job gets, without declaring a job. DwTestServer.runInContext forwards it. No caller identity is offered: a context that claims an account without a session key would let code act as someone no request authenticated.U90 had no way to call a domain service that takes a context outside a handler or job, and wrapped one in a recurring job run by hand in its test harness. Scripts that publish (a seed announcing what it created) had the same gap.no
D-066A publication can be kept from named accounts: ctx.publish(channel, item, exceptAccounts: {...}). The hub sends each connection only the items not kept from its account, encoding each distinct selection once per channel (a channel without exceptions keeps the single shared frame); a command's response leaves out items kept from its caller. Objects collapse to their last publication before the filter, so an account excluded from the final version never receives an earlier one. The framework does not know why (a block, a hidden thread): the project names the accounts. Refines D-053.U90: in a group chat, a member who blocked another still received the blocked member's messages live and hid them on the device — the text travelled anyway, against "nothing extra over the network", and a channel per member would multiply subscriptions and publications.no
D-067A client can hear channels without reading: DwAppClient.listen(channels) (and dw.listen) returns a stream of the objects published to them. A listener is a channel member beside request entries — the same reference-counted subscription, released on cancel, caller channels re-resolved on a switch of account — and absorbs updates by emitting them; it has nothing to reload, so a reconnect replays nothing. A page request's size stays a constant of its class (D-029): a client that wanted fewer rows wanted no rows.U90's "N new posts" badge had its count from GetMyCommunityReaderState and read a feed page of 20 posts only to be subscribed; its workaround added a page-size field to its contract to read one row. "Nothing extra over the network".no
D-068A deployment step runs detached on the server, and the server keeps the record a resumed run continues from. Each step's script goes to ~/.config/<project>/deploy-run/ and runs under setsid with its streams in files and its exit code written last; the starting ssh call waits for it and fresh calls wait again after a broken connection (15 minutes' tolerance). deploy run --resume follows the recorded plan: done steps are passed over, a running one is waited for, a finished one with a verdict is judged again from its output, a rejected one (exit 0, verdict refused — recorded by the CLI) runs again, and every step after the first that runs, runs. A new run refuses while a recorded step is still running. --progress json writes events as JSON lines on stdout, prose on stderr.dartway/dartway#266, Studio (STD-146): a deploy from a container of the stack being replaced killed its own CLI and ssh mid-step, leaving the old server stopped and nothing to finish the proxy restart; Studio parsed prose to follow progress. Proven on Linux: the step survives HUP and KILL of the whole ssh session, and dies without setsid. Detachment per step rather than one server-side script for the whole deployment: the verdicts (the upstream check) are Dart, and the server has no CLI. The start call waits in place so a routine deploy adds no connection; the check for a running deployment and the reset of the record travel with the first step for the same reason.no
D-069Analytics is a module that keeps events in the project's Postgres: dartway_analytics_shared / _server / _flutter. A project names its events in an enum with DwAnalyticsEvent; the server stores any well-formed name, so a new event needs no server deploy. The app plugin DwAnalytics numbers each event per install, keeps it on the device, and sends batches (DwTrackEvents, up to 100) on an interval, at a batch size and on backgrounding; the server stores each (install_id, sequence) once, so the command keeps no idempotency outcome (recordsSuccess: false, now public). The install id is created once and kept, linking events before and after a sign-in; the account is the caller's, never a field. Sessions are numbered by the server by a 30-minute gap under the install's row lock. ctx.analytics.track records server-side facts in the caller's transaction. Framework events dw.appOpened (with project-read attribution), dw.appResumed, dw.appBackgrounded, dw.accountChanged. Retention 180 days. No reports, no export, no screen tracking yet.Owner, 2026-09-17: "нам понадобится отслеживание событий внутри приложения… реализуй модуль", to be connected to U90 and Molodey. Built from Tvaity's in-house analytics (0.x), keeping what worked — own storage, named events, attribution, retention — and fixing what did not: one HTTP call and a session upsert per event, an anonymous id reset every launch (sessions were launches, pre-sign-in behaviour unlinkable), events lost offline, string-only properties, a closed enum on the wire requiring a server release per event. Own storage over a third-party SDK: client contracts list every third party that receives data, and a Russian audience's data stays in the country.no
D-070A deployment runs one version of the server at a time. The serving server stops gracefully, the new image applies the migrations in a one-off run that serves nothing (DW_MIGRATE_ONLY=true — DwAppServer.start() migrates and ends the process), then the new server starts and must become healthy; on a failure the image that was serving is started again. Replaces starting a candidate server beside the serving one. The gap (stop + migrations + start, seconds for a routine deploy) is covered by the client, which retries network failures and a gateway's 502/503/504 for up to callTimeout with the same idempotency key, and by live sockets reconnecting.Owner, 2026-09-17: "не должно быть пересечения серверов, должно четко всё работать… возможно должен быть какой-то даунтайм, чтобы запросы не терялись". The candidate was a full server for its seconds: it took jobs from the shared queue (the unknown-recurring-job incident, fbc138f), and while it migrated the old server kept writing into the new schema (a NOT NULL column without a default failed the old server's inserts, found by Studio). Zero-downtime would need every migration to be compatible with the previous code — a discipline projects would have to hold by hand — for a gap the client already absorbs.no
D-071Every enum is strict, and an unknown value means the app is out of date. A name a strict enum does not have is DwUnknownEnumValue; the client, meeting one in a call result, a watched request or a live update, becomes incompatible with dw.updateRequired — the update screen — rather than failing the call or the list. DwOpenEnum (with a value named unknown) is the exception by declaration, for display-only values: readers — codecs and row columns — turn an unknown name into unknown, and unknown is never stored (writing it to a row throws), so no build overwrites a value it did not know; on the wire it travels as itself, so an older server answers from a row it cannot read instead of failing the answer (Studio, 2026-09-18: the generator writes value.name for DTOs, deliberately). dartway generate refuses an open enum without unknown. A command carrying unknown is answered dw.updateRequired (owner, 2026-09-19, #274): the write still throws — nothing is stored — but on a call the throw becomes the update screen rather than a 500 with an alert, because the caller is a build older than the data it is writing and cannot correct its input, only update. Elsewhere — a job, a module, a subscription — it stays a failure: a write with no caller has nobody to tell.Studio (#271): each new enum value broke open app builds four times a day, remedied by raising the minimum build. Owner, 2026-09-17: the point of an enum is certainty that every case is handled, so an unknown value must not be quietly absorbed — the app must say clearly it is out of date and stop; open enums may exist but are rare and need clear rules; and on the server nothing may be overwritten.no
D-072Deleting an account is the framework's. DwDeleteMyAccount (signed in) and DwAccountService.deleteAccount run in one transaction: DwAuthConfig.onAccountDeleting (the project deletes or anonymises its rows; refusing keeps the account), the account's stored files (objects deleted after commit), every session key revoked (sessions close after commit), then the code tickets addressed to its identifiers and the recorded outcomes of its commands (both would otherwise sit until the cleanup job, carrying a phone number or a result), then the account — identities, keys and push devices by cascade; analytics keep events without the account. dw.deleteAccount() ends the session once the server confirms; the template's profile page has the button behind a confirmation.App Store Review Guideline 5.1.1(v) requires in-app deletion from every app with sign-up; none of the projects had it, and each would have written it against the framework's tables. Prerequisite of Sign in with Apple, whose tokens must be revoked on deletion (owner, 2026-09-18: "да, давай делать").no
D-073A project keeps a tombstone for a deleted member, not a hidden account. In onAccountDeleting a row is deleted when it is about that person alone (their drafts, settings, files; a held booking is released), and kept when somebody else holds on to it (a message in a shared chat, a post, a review) — with the profile row itself emptied: account_id nullable and ON DELETE SET NULL, deleted_at stamped, every personal field cleared, isDeleted on the data objects, "member who left" on the screens. Hiding the account behind a flag while keeping the name and the phone is not a deletion and is not allowed. The app says which route it takes before it asks to confirm. example/ carries it end to end; the template deletes the profile outright and says in auth.dart when that stops being true.Owner, 2026-09-18: "мне как-то cto говорил, что никто ничего не удаляет в таких случаях по факту, просто прячется / но это как будто хитрость, а надо быть честными / но не ломать систему себе" — and "должен по идее сохраняться профиль - типа удаленный пользователь номер 10". Deleting the profile row would take other people's history with it (a chat losing its authors); keeping the person behind a flag would be a lie to the member and to the store.no
D-074The framework verifies the providers' tokens; the app only fetches them. dartway_auth_providers_shared carries the command and the refusals, dartway_auth_providers_server (DwSignInProvidersModule with DwGoogleSignIn / DwAppleSignIn) checks a DwSignInWithProvider token against the keys the provider publishes — signature by the key's own algorithm (RS256, ES256), issuer, expiry with two minutes of skew, nonce as sent or SHA-256'd, audience against a list of client ids — and signs in dw_identity(kind: provider, value: subject). Keys are fetched and held by Cache-Control, refetched for an unknown key id at most once a minute, and kept when the provider is unreachable. dw.providerCredentialRejected and dw.providerUnreachable are told apart; the reason a token failed goes to the log, not to the app. Verified claims reach onExternalAccountCreated under reserved dw. keys, which the app cannot write. Separate packages rather than the core protocol: a server that offers no provider must not register a command it has no handler for — that is a server that refuses to start. The verification is written here, not taken from a dependency, as the FCM signer is (D-034), and is tested against tokens openssl signed.Owner, 2026-09-18: "займешься входом через гугл и apple", "опции независимы должны быть". Google says outright not to write this check by hand — which is exactly why it belongs in the framework once rather than in every project: an error in it is silent, and a wrong aud turns away a whole platform's users (Android, iOS and web have different client ids).no
D-075A deploy renders the stack it is about to run. docker-compose.yml and nginx.conf are rendered on every deploy run from deploy/config.yaml and the CLI's own version, reported as "unchanged" or "rendered again", written through cat > so a bind-mounted file keeps its inode. Reverses the earlier rule that only setup writes them. Images must also build what was committed: every pub get in the template and the example runs --enforce-lockfile, and the locked-dependencies check fails a project whose images do not.U90, 2026-09-18: a CLI that had learnt to pass STUDIO_APP_ORIGIN met a compose file rendered before that argument existed; the deploy died inside docker build blaming the project's Dockerfile, while the file to fix was on the server and in no repository. The same day, a pub get without the flag resolved a different package set in the container and then could not read its own package_graph.json. Both are the same mistake: work carried out against an artefact nobody re-derived.no
D-076Revoking a person's tokens with Apple is a job, not part of the deletion. The app sends Apple's one-time authorizationCode with the sign-in; the server exchanges it for a refresh token (DwAppleSigningKey: the .p8, the key id, the team id) and keeps it in dw_provider_token, which no handler reads. Deleting the account enqueues dw.auth_providers.revoke with that token and deletes the row, so the deletion never waits on Apple and never fails because Apple is down; the job retries. A failed exchange never fails a sign-in. DwServerModule.accountDeleting is the seam this hangs on: every module is told inside the deleting transaction, after the project's hook, while the account is still there to name.Apple requires the revocation of any app offering Sign in with Apple (5.1.1(v)); doing it inline would mean a person cannot delete their account while Apple is unreachable, which is the opposite of what the rule is for.no
D-077A server does not start while deleting an account would take project rows nobody decided about. At startup it follows every ON DELETE CASCADE from dw_account transitively; project tables among them with no DwAuthConfig.onAccountDeleting set are a refusal naming them, and with the hook set they are logged on every start. Each is named with the path that reaches it (dw_account → user_profile → team_invitation), which is what tells a row of the person's own from a row where somebody else is involved. The ways out: the hook (delete or tombstone), a refusal inside it while the project has not decided, or an empty hook with a comment.Studio, 2026-09-18: a project's user_profile cascaded off dw_account and its survey_answer off the profile; adding DwDeleteMyAccount to the framework (D-072) made both deletable from outside on the first deploy after the pin moved — "profiles=0, answers=0", proved by running it. Nothing in the project changed, nothing failed to compile, no test went red, and its admin screens kept showing people who no longer existed. A framework that adds a door owes the projects behind it a word about what is on the other side.no
D-078local is an environment of deploy/config.yaml, and a project's entry points read it. The two files that describe every environment describe the developer's machine too: config.yaml > local holds what the team shares (the coordinates of the development containers, committed because they guard nothing and are identical for everyone), secrets.yaml > local holds what is that developer's own (git-ignored, as it already was). DwLocalEnvironment.overlay(Platform.environment) in bin/server.dart, bin/seed_dev.dart and bin/migrate.dart puts both into the environment, in that order, with a real environment variable over them; the framework server still reads no file — the project's entry point does, in one visible line. A deployed server has neither file by construction: .dockerignore admits only the packages into a build context and the runtime stage holds the compiled binary alone, so there is no run mode and no "am I in development" flag. requires is hoisted to the top of config.yaml (an environment's own adds to it), dartway deploy secret … becomes dartway secret … with --env local backed by the file instead of SSH, and two advisory checks appear: localSecretMissing and devComposeDrifted. Costs yaml in dartway_core_server, carried into every production binary and never called there.Owner, 2026-09-22: after the rewrite there was no answer to "where do local secrets live, and how do I see what is missing" — the deploy side had a declaration, a store and secret list, and the local side had a block of DW_DATABASE_* copied into docker-compose.yaml, the README, .vscode/launch.json, quickstart_brief.dart in the CLI and the example's README, with nothing checking that the five agreed and nowhere at all to put a real API key (launch.json is committed). envFile in a Dart launch configuration would have solved the IDE half and does not exist (Dart-Code declares only env). The owner's objection to my first shape — a server that never reads a file, at the price of splitting the configuration into more files — was decisive: "хочется простоты, вот здесь всё для dev". One file per environment was rejected because the Git boundary runs across environments, not along them: staging.yaml would still split into a committed half and an ignored one, six files instead of two.no
D-079Startup steps, and no fixture mechanism. DwAppServer(startup: [...]) is work done at every start, after the migrations and before the port opens, in a background context and one transaction; a step declares its configuration problems to validate() and stops the start when it throws. DwFirstAdministrator is the framework's one built-in step: it reads DW_ADMIN_IDENTIFIER (renamed from the skeleton's APP_BOOTSTRAP_ADMIN — the name is the framework's now), ensures the account the way a sign-in does and hands it to the project's grant, which gives the role the framework knows nothing about. It runs at every start, so an identifier demoted in the panel is an administrator again — the only way back into a project that locked itself out. Deliberately not built: a mechanism for development data. The seed stays a script (bin/seed_dev.dart), with its one real defect fixed — it starts the project's own server on port 0 and works through runInContext, so the accounts are created by the project's real DwAuthConfig instead of a second copy of it declared in the script. Where data belongs is settled by who owns the row afterwards, and that rule lives in the documentation rather than in an API: rows the operators own once they exist are a migration (written in SQL, never through row classes, which would change meaning under an unchanged checksum); rows that must keep agreeing with the code are a startup step; development data is a script.Owner, 2026-09-22: the first administrator was implemented differently in every project (the template had sixty lines called by hand after start(), the example had none at all and relied on its seed), and the seed re-declared DwAuthConfig — a second copy that is wrong the first time the real one gains a field, as it had twice. A fixture system with a ledger, a guard and an admin-panel button was designed and then rejected by the owner: "как будто нет смысла в интерфейс это выводить" — what data to seed is project-specific, so a framework wrapper around it would be scaffolding in every skeleton forever, and the panel screen would be invisible in production and maintained anyway. The owner also found the hole in the first version of the data rule: a migration cannot be edited once applied and a down for data deletes rows somebody has since corrected, so anything that changes with the code was never a migration to begin with.no
D-080A migration note is written in the same pull request as the change, with no exemption — and the version moves to deliver it. A change inside the rewrite that asks a project to edit its own code carries a note in docs/migrations/ in the same pull request, and raises the version it is keyed to: the family's prerelease in lockstep (0.20.0-dev.1 → dev.2), or the satellite's own — here dartway_cli 0.10.1 → 0.11.0, because a pending patch in front of a breaking change becomes a minor. Each dev.N is a release to the projects that follow the branch, whatever it is to pub.dev. Replaces the rule that no note is written before the rewrite is released (docs/migrations/README.md, CLAUDE.md item 9, framework-finish step 5). What survives of D-031: a project coming from 0.x is recreated rather than migrated, databases included, and that journey gets no note.Owner, 2026-09-22: "версии - это версии, они показывают, что что-то изменилось, а где записать инструкции что конкретно нужно поменять и как… чтобы проект не на своё усмотрение это решал". The old rule assumed projects port once and then stand still; Studio, U90 and Molodey are on the rewrite and follow it, and the mechanism could not reach them at all — a note is shown to a project below the version it names, and the family had stood at 0.20.0-dev.1 since the rewrite began, so every note written against it was invisible to everyone. The folder had already outgrown the prose: two notes keyed to 0.20.0-dev.1 were written during the rewrite while the README said none would be.no
D-081The repository is written against one Flutter and promises another. The pin — .fvmrc in the root, example/ and template/, and every workflow — is the SDK the gate runs on, now 3.47.2 (#280); tool/checks.sh refuses to run on any other. The floor — flutter: '>=3.44.0' in dartway_core_flutter, the workspace root, the example and the template, and dartway doctor — is the oldest SDK the family compiles on (#290). The floor is compiled on purpose in one place: the web images build on ghcr.io/cirruslabs/flutter:3.44.0, so images.yml, building the template's, fails when the code stops compiling on it. Satellites keep their own floors.3.41.7 failed dartway_core_flutter while the stated floor was 3.41; nothing compiled the floor, so the promise was never tested. 3.47.2 moved private SDK shapes the framework read (the Duration constant, the IndexedStack wrapper), which only a pin that moves finds. cirruslabs publishes no image past 3.44.0, which makes it the floor's natural check rather than a lag.no
D-082A project's web image installs the Flutter its .fvmrc names, and the floor is compiled by a job of its own. The build stage starts from debian:bookworm-slim, copies <project>_flutter/.fvmrc and clones Flutter at that tag; dartway deploy check refuses a web image built FROM a Flutter image whose tag differs from .fvmrc (web-flutter-version). The floor D-081 promises is compiled by web-compile.yml's floor job: the template, resolved afresh and built for the web on Flutter 3.44.0. Reverses the part of D-081 that made the web images the floor's check.Flutter pins some of the packages an app resolves (meta, vector_math, test_api, …), so a lock written on 3.47.2 is refused by pub get --enforce-lockfile on 3.44.0: after the pin moved, the web image of every project dartway create makes could not build, and the image lagging the pin was not a free check of the floor but a broken deploy. cirruslabs publishes nothing past 3.44.0, and whichever image a project picks, .fvmrc is where it already states its Flutter.no
D-083dartway_lints is an analyzer plugin, and not a workspace member. Its rules run in the analysis server's own plugin system (analysis_server_plugin), enabled by a top-level plugins: dartway_lints: in a project's analysis_options.yaml and fetched by the server — a project neither depends on the package nor on custom_lint. The skeleton names it by a path into this repository, which dartway create replaces with the version the template was taken from. The package pins the analyzer the plugin API is written against (14), which the workspace cannot hold, so it resolves on its own as dartway_generator does; test/example_test.dart runs dart analyze over its fixture. flutter analyze does not run plugins: the toolkit's command is dart analyze --fatal-infos.On Dart 3.13 custom_lint crashed inside the analysis server (Unknown request: analysis.setAnalysisRoots) while dart run custom_lint passed: the rules silently left every IDE on the new pin (#295). The plugin system is the SDK's own, loads on Dart 3.12 and 3.13, and reaches dart analyze without a second command.no
D-084The project's contract version decides which apps a server serves, and it lives in the code. The shared package's version: is the contract version: the generator writes it into the protocol both sides are compiled with (DwWireProtocol.contractVersion), the client sends it as Dw-Contract-Version (?contract= on the socket), and a server answers a client of an older breaking line — the major version, or the minor one below 1.0 — 426 dw.updateRequired. A client of a newer line is not refused: the server is behind, and an unknown call is answered as unknown. DwServerSettings.minAppBuild and DW_MIN_APP_BUILD are removed; Dw-App-Version stays as a session key's label. dwProtocolVersion is 2.#296: the minimum lived only in an environment, raised by hand at deploy by someone other than the author of the breaking change — a web project removed a command, never set the variable, and open tabs met "unknown call" instead of the update screen. Owner, 2026-09-23: the minimum belongs in the code, in the shared package both sides build against, by the usual versioning rules; an unknown request stays unknown.no
D-085The rewrite is published to pub.dev, and the family goes out as 0.20.0 — not a prerelease. dartway_core_shared/_server/_flutter/_orm/_client/_generator move from 0.20.0-dev.4 straight to 0.20.0; dartway_auth_apple/_google/_providers_server/_providers_shared and dartway_analytics_flutter/_server/_shared from 0.1.0-dev.1 to 0.1.0; dartway_studio_binding from 0.2.0-dev.1 to 0.2.0. dartway_push_shared/_server/_flutter/_firebase/_rustore move from 0.3.0-dev.1 to 0.5.0 — above the old, Serverpod-based dartway_push_server 0.4.0 already on pub.dev, since a published version can never be superseded by a lower one. Satellites that already carried a published version under the old framework, with the code underneath since moved onto the rewrite without the version following — dartway_studio_bridge 0.9.0 → 0.10.0, dartway_shared_preferences 0.5.0 → 0.6.0, dartway_telegram 0.2.0 → 0.3.0 — move a minor rather than adding a prerelease suffix to a version already out. dartway_cli, dartway_lints and dartway_router keep their versions, already ahead of pub.dev. Every remaining publish_to: none under packages/ is removed.Owner, 2026-09-24: the release is global — three projects (Studio, U90, Molodey) already run the rewrite in production, so there is no cohort left to protect behind a prerelease tag while the family stabilises. A prerelease on the family would also force every dependent satellite into a prerelease of its own, since a satellite cannot state a stable caret on a prerelease dependency — the opposite of what the family's own lockstep versioning promises its satellites.no
D-086Between releases, a migration note is delivered by a prerelease of the family's next version. After 0.20.0, the first change that needs a note moves the family in lockstep to 0.21.0-dev.1, each further note to the next dev.N; the release publishes the plain 0.21.0. A satellite raises its own version as before. The rule is not reversed by D-085: that decision took the family off a prerelease for its first publication, while this one is about the window between releases.Owner, 2026-09-24. Studio, U90 and Molodey follow the framework by git revision, and a note reaches a project only while it stands below the version the note names — so every note needs a version of its own before anything is published. A prerelease gives that without spending plain versions on pub.dev for each note. The cost, accepted: a satellite that depends on a family prerelease can itself be published only as a prerelease until the release.no
D-087generateCode and deliverCode are independent hooks of DwAuthConfig; fixedCode is gone. generateCode(ctx, kind, identifier, accountId) → Future<String?> decides the code alone — null, whether generateCode is unset or returns it for a call, draws codeLength random digits (dwRandomCode), so a hook that fixes the code for a few identifiers and defers to the framework for the rest never has to call dwRandomCode itself or risk a length that disagrees with codeLength. deliverCode(ctx, kind, identifier, code, accountId) now runs always, whatever the code is; sending nowhere for a fixed code is the hook's own choice, made by returning without sending, rather than something the framework decided by withholding the call. accountId is the account identifier already belongs to (or null), the same value both hooks receive — not the caller attaching it, which stays ctx.accountId. deliverCode runs after the ticket's transaction has committed, not inside it — _requestCode now opens its own ctx.transaction for the limit check, the code and the ticket, and calls deliverCode once that has returned: a project's deliverCode is commonly an HTTP call to a provider, and running it inside the transaction — under the identifier's advisory lock — held a pooled connection, and that lock, for as long as the provider took to answer. The ticket is written, and already counted against the limit, before delivery is attempted, so a deliverCode that throws (or refuses) no longer undoes it; the caller sees the failure and the next attempt waits out resendDelay, the same as any resend. DwRequestCode and DwRequestIdentifierCode are transactional: false at the handler level for this (as DwVerifyCode already was, for its own reason). A transactional: false command whose transaction commits before it finishes may call DwRuntimeContext.recordProvisionalOutcome — internal, not on DwCallContext; only _requestCode reaches it, cast down from the DwCallContext its handler signature is given — from inside that same transaction, with what its answer will be if nothing past that point fails. _requestCode does, with the ticket, so a duplicate send of the same idempotency key arriving once it commits, but before deliverCode returns, replays that instead of running the handler again (which would repeat the transactional part — a second ticket — for one key, and meet its own resend-delay refusal). If the handler then throws, DwCallEndpoint settles the record: a refusal upserts it (an update alone would miss a row that itself rolled back with a transaction the handler's later throw is still inside of — _requestCode's own shape never hits this, but the mechanism has to hold up for a handler whose transactional part continues past the provisional write), anything else clears it — so a later send never replays a stale success, and an incident never replays at all. _validate and _check moved out of the transaction/try this split needed, as a side effect: a refusal from either is no longer recorded under the idempotency key (transactional commands: only _validate's is affected — _check still runs, and is still recorded, inside the transaction) — accepted, since both are pure functions of the call's input and refuse identically on a replay regardless. First release: 0.21.0-dev.2, not dev.1 — #311 (this) and #312 both claimed dev.1 on their own branches before either had landed; #312 merged first and kept it, so this note is numbered for the family's next prerelease instead of colliding with one already on master (D-086: a note is invisible to a project already at or past the version it names). Migration note docs/migrations/2026-09-24-generate-deliver-code-split.md.Owner, 2026-09-24 (issue #310): "фиксированный это код или нет и куда он уходит" were one decision under fixedCode, and the first real requirement needed them apart — Molodey wants a default sign-in code from its own settings and to send it by SMS when a toggle is on, which fixedCode could not express without a project reaching around deliverCode to send from inside fixedCode itself, duplicating the framework's own delivery seam. Every existing user of fixedCode in this repository (the example, the template, the test fixtures) moved the same way: the profile lookup into generateCode, one if (fixed code) return; guard into deliverCode. The transaction split followed from review of the same Molodey work (SMSC over HTTP): deliverCode inside the ticket's transaction, under the identifier's advisory lock, meant a slow provider held a pooled connection for as long as it took to answer — a pool of 10 exhausted by a handful of slow sign-ins would stop the whole application, not just sign-in.no
D-088A browser upload no longer stalls and restarts on a large file (#309). DwAppClient picks DwXhrStorageTransport (XMLHttpRequest) for storage puts on the web by default now, chosen the way DwNativeSplash picks its web half — dw_storage_transport_stub.dart / _web.dart behind if (dart.library.js_interop) — since dartway_client is plain Dart and has to keep compiling and testing on the VM and on Node. fetch (DwHttpStorageTransport, still the default off the web) reads the whole request body before sending it, so its progress jumped to the end immediately and the stall watchdog, seeing nothing more, aborted a transfer that was still going. DwStoragePut gained reportSent(int sentBytes), optional and a no-op by default: a transport calls it with bytes actually reaching the network, separately from reading body — DwHttpStorageTransport calls it as it pulls each chunk, DwXhrStorageTransport from upload.onprogress. Reading body still keeps the watchdog alive on its own (a slow source is not a stalled network) and, until a transport calls reportSent for the first time, drives progress exactly as before this existed — a transport that never adopts it loses nothing. A first version also gave the watchdog a second, longer phase once the whole body was reported sent, bounded by the ticket's remaining validity instead of the stall timeout — found wrong in review (Opus): DwHttpStorageTransport calls reportSent as it pulls a chunk into the socket, and a body that fits the OS send buffer can be pulled in one go, long before the network has taken it anywhere; a storage host that accepted the connection and then answered nothing turned from "retried every callTimeout" into "silently stuck for up to the ticket's lifetime" — worse than before this existed, for the plain dart:io path. Removed: the watchdog is always stallTimeout, re-armed by real progress signals however they arrive; a transfer still moving is never mistaken for dead, and one that stalls is caught the same way it always was. This moves the family to 0.21.0-dev.1, D-086's line, not a patch — #311 (#310, breaking) reached the same 0.20.0 first and needs a migration note of its own, and D-086 does not hold two tracks side by side: a plain 0.20.1 for this and a prerelease 0.21.0-dev.N for that would ask a project reading docs/migrations/ which of two numbers it is even behind. Raising a satellite's own caret on the family — dartway_telegram and every other workspace member still holding ^0.20.0 — to keep the workspace resolving against the new minor is not that satellite's own release; its version is untouched. Numbered D-088, not D-087: #311 claimed D-087 first, on its own branch — neither had landed when both were opened, so whichever of the two merges second resolves the pubspec/CHANGELOG conflict mechanically and checks this table for a numbering collision the other did not cause.Issue #309 (owner): the fetch-based transport's own doc already named the bug; large uploads never completed from a browser.no
D-089Who may delete an account is a required choice: DwAuthConfig.accountDeletion, DwAccountDeletion.byMember or byOperator. byMember answers DwDeleteMyAccount as before; byOperator refuses it dw.forbidden, and ctx.accounts.deleteAccount from server code works either way. Required rather than defaulted: byMember as a default is how the command went live unasked, byOperator would ship apps the stores reject. The refusal is the core forbidden, not a new DwAuthRefusal code, so no project's exhaustive switch over the auth refusals breaks.The structure audit of u90, Studio and Molodey (2026-09-24): Studio and Molodey both refused DwDeleteMyAccount inside onAccountDeleting, each with a refusal code of its own, because the framework offered no other lever — and that refusal also blocked the operator's own deletions.no
\n
D-090An access rule may load the resource it guards: DwAccessRule.resource<C, R>(load:, allows:), read by the handler as ctx.accessed<R>(). Signed-in required; load returning null and allows answering false are the same dw.notFound, so a caller cannot enumerate other people's rows. The row travels through the call's memo, which a retried transaction clears before the rule runs again. check stays for rules on the call's parameters and roles, which answer forbidden.The structure audit of u90, Studio and Molodey (2026-09-24): u90 had 132 signedIn handlers with ownership checked inline (the same check 19 times, three different rules for one chat membership — one of them skipping the block check); Studio passed projectId in every request only so check could see it, and re-checked that the object belonged to the project at 41 sites. check could not see the row without reading it twice.no
D-091A server answers contract calls in process: DwAppServer.callAs(call, token:, idempotencyKey:, page:). It runs the same steps as an HTTP call after decoding — the session, sign-in, validation, the access rule, the handler, idempotency, the transaction, publications — on the call object itself, and answers the typed DwCallResult. A command without a key is a new intent each time. It takes a token rather than an account id: the door acts for a session someone holds, and a revoked key stops working here as everywhere.Studio's MCP door called its own server over loopback HTTP with hand-built headers (Dw-Protocol: '1' against a framework at protocol 2), a random idempotency key per call and a build number claimed to pass the app-version gate; its import script reused the same self-caller.no
D-092A job is typed: DwJobKind<P> (name + payload codec) is what it is, DwQueuedJob<P> (kind + handler + retry policy) is how it runs, and ctx.jobs.enqueue(kind, payload) takes the kind. The two halves are apart because a handler is often built from a service instance (the file store, the push worker, a project's services object) while the places that enqueue are not. The payload stays server-side JSON, not a protocol DTO (D-016 stands): a codec on the kind, written once — a record type needs no class. Enqueue checks the server declares a job of the kind's name.The structure audit (2026-09-24): every job in u90 (7) and Studio (8) read payload['x']! as int in its handler and spelled the map again at each enqueue; Studio declared jobs in four shapes and named them in three conventions.no
D-093A server's lib/src/ is laid out by feature, and the server is its features. src/ holds folders only: core/ (auth hooks, the caller and access rules, channel addresses, upload rules, startup steps), migrations/, and one folder per area, which holds all of the area — rows, handlers, objects, publications, jobs — and declares a DwServerFeature(name, handlers:, channels:, jobs:, routes:) in <feature>_feature.dart. DwAppServer(features:) replaces its four lists (one way, not two). Layer folders at the top of src/ are refused. dartway check holds it as invalidTopLevelLayout, an error, like the Flutter package's top level. Upload rules stay with the storage in core/: a purpose is often shared, and DwFileStorage takes them whole.The structure audit of u90, Studio and Molodey (2026-09-24): src/ was declared "the project's, the skeleton's shape not a law", and the three arranged it three ways — Molodey by layer, u90 and Studio by layer and by feature at once, so one area (u90's chat) lived in rows/, handlers/, chat/ and domain/chat/, with three rules for chat membership that disagreed; Studio's agent runs lived in three folders. Owner, 2026-09-24: the list of changes agreed, "делай всё".no
D-094The bundled storage is RustFS (rustfs/rustfs), replacing MinIO: storage: minio in deploy/config.yaml becomes storage: bundled, decoupling the config value from any one product — the second time this stack has had to leave a storage vendor. Bucket setup in storage-init moves from MinIO's own client (mc, also gone) to a generic, pinned S3 client (amazon/aws-cli) that reads AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY from its environment; bucket CORS moves from a server-wide environment variable (which MinIO's community edition needed, having no PutBucketCors of its own) to a put-bucket-cors call in that same init step, on both buckets. dartway deploy check gained a check that resolves every pinned base image against its registry (a HEAD of its manifest, with the anonymous token the registry's own challenge asks for) — MinIO's failure surfaced only once deploy run reached the step that started it, well past the images that build locally. The compose volume is renamed (storage_data); a project already running MinIO in production follows docs/migrations/ to move its bucket contents once.dartway/dartway#331: MinIO's community edition stopped publishing images, and every registry that used to serve them (Docker Hub, quay.io) answers an anonymous pull with 401 — dartway deploy run fails on a fresh host, and the test suites fail on a clean machine. Owner, 2026-09-26: move once, now, rather than re-host the dead images.no
D-095deploy_user defaults to the fixed name dw_admin — not one derived from the project — when deploy/config.yaml does not set it; deploy setup's guard against joining a group a sudoers rule already grants privileges to stays exactly as it was, refusing rather than reusing such a group. The guard reads %<name> lines it can find in /etc/sudoers and /etc/sudoers.d/* by a fixed pattern — a name reached only through a User_Alias or group list, a sudoers alias, the %#gid form, or a rule pulled in by an @include/@includedir outside /etc/sudoers.d, is not seen. Accepted, not chased: the user setup creates is always --disabled-password, so there is no password for that rule's own sudo prompt to authenticate against, and the same step always adds it to docker, itself root-equivalent (a container can bind-mount the host root) — a rule the guard misses grants no privilege the deploy user does not already have through docker alone.Owner, 2026-09-26 (dartway/dartway#341, #328): DigitalOcean's Ubuntu 24.04 image ships an empty admin group that collides with the natural default name and, through Ubuntu's stock %admin sudoers rule (Debian ships only %sudo), would be a privilege escalation to join silently — stop the collision at the source with a default that never collides, and keep the guard for whatever config still names an explicit deploy_user that does.yes
D-096database: bundled | external in deploy/config.yaml, the same vocabulary as storage — a managed Postgres an environment points at instead of the stack's own container. With external: no postgres service, volume or depends_on; DW_DATABASE_HOST, _PORT, _NAME, _USER, _PASSWORD are required secrets (a managed provider names its own host, port, database and role — none of them derivable the way the bundled container's are), DW_DATABASE_SSL (default true) and _MAX_CONNECTIONS (default 10) stay optional, and the secret store accepts these names precisely because the compose file no longer sets them (and still refuses them for bundled, where it does). dartway deploy check gained database-reachable: DW_DATABASE_PORT/_SSL/_MAX_CONNECTIONS are validated exactly as the server parses them, then, on the deployment host over SSH (a managed provider's firewall trusts that address, not the maintainer's machine), a throwaway, pinned Postgres client attempts a real connection with the same sslmode the server itself will use (require unless DW_DATABASE_SSL is explicitly false) and classifies a failure — a bad stored value, DNS, refused, a timeout, auth, or TLS not offered — never a bare TCP probe, which would miss the last two. sslmode=verify-full with a CA file is a separate issue (dartway/dartway#342): a new secret-file wiring and a driver change, not a rename.dartway/dartway#325: the only way to run a project's database off the deployment droplet was an untested compose.override.yml hack with the local Postgres still running and depended on; first production goes on DigitalOcean Managed Postgres, where losing the droplet must not mean restoring the database from a snapshot too.no
D-097secret push no longer overwrites a differing server value by default: it adds a key the server lacks or holds empty, leaves a key that already matches, and refuses the whole push — nothing sent — naming every key whose server value differs. --overwrite KEY[,KEY…] replaces exactly those named keys on purpose; there is no --overwrite-all, since naming the key is the point. A generated key (DwStack.generatedSecrets — the database password for database: bundled, and with storage: bundled the storage keys) is bound to the data already on the server on every path that touches it, not only replacing it: dropping it with --prune or blanking it with --allow-emptying also needs it named in --overwrite, on top of whichever of those two flags would otherwise be enough on its own for an ordinary key. The comparison itself runs on the server (DwSecretStore.plan), over the candidate's own encoded lines sent on stdin — only three sets of key names, every key name the store currently holds (read in that same pass, so --prune's orphaned keys are never computed from a second, separately-timed read of the store), and a cksum fingerprint of the store travel back, never a value, keeping the store's existing rule that values never leave the server. That fingerprint is what writeAll is asked to check the store still matches right before it writes, since planning and sending are two calls, not one transaction — a second push, or a hand edit, landing in between refuses the write rather than silently undoing it, and the plaintext staged for that write lives in the store's own directory, never the shared system temp one. A store that exists but cannot be read (permissions) fails the plan outright rather than reading as absent, which would have classed every key add and let the push through as a replace. --prune and --allow-emptying are otherwise unchanged; push prints a per-key plan (add / keep (same) / overwrite / differs — refused / drop) before sending anything, the same shape --dry-run already printed, and a candidate key the plan does not place in any of its three sets is refused, never sent unexamined.dartway/dartway#330: the common path of moving an environment to a new host — setup generates fresh DW_DATABASE_PASSWORD and storage keys, deploy/secrets.yaml still holds the old host's — made the "obvious" push replace the freshly generated, volume-bound values with the old ones; on a live server the same silent overwrite breaks the database connection on the next run. Two rounds of review of this fix (PR #347) found: plan reading an unreadable store as an absent one (silent replace again, through a permissions accident); a --prune/--allow-emptying path around the generated-key guard; an unclassified key slipping through unrefused; no guard against the store changing between planning a push and sending it; that guard's own fingerprint and a separate readKeyNames call reading the store at two different, un-atomic moments; and plaintext staged in the shared system temp directory rather than the store's own.yes
D-098DwPushPermission gains unanswered, distinct from notDetermined; DwPush.requestPermission() and .permission() return it once a new, separate DwPush(permissionDeadline:) (10 s by default) passes without an answer, and permission() bounds the transport's own call by it too, once attached. They used to await _attached.future with no bound of their own — BoundedPushTransport, which the issue names as the thing that used to cap this, is not traceable anywhere in this repository's history, consistent with D-010/D-031 (the pre-rewrite push module was dropped, not migrated). A first version reused reportUnansweredAfter and answered notDetermined on timeout — reverted the same day, before merging: review found it turned a visible hang into a silent wrong one — U90 saves its toggle as on regardless, nothing asks the OS again, and permission() would tell a user who granted long ago "nobody has asked yet", which is false. permissionDeadline is its own duration because reportUnansweredAfter answers a different question (when to report a background call, not asked on anyone's behalf) — a project lowering it to see trouble sooner must not, as a side effect, also make these two give up sooner on the user. requestPermission()'s own call to the platform stays unbounded throughout: it may be showing a system dialog, and the user's own time answering it is not silence. Breaking under 0.x (0.6.0, not a patch): an exhaustive switch over the enum stops compiling, migration note in docs/migrations/. Numbered D-098, not D-096 or D-097: both are claimed by branches in flight (#344 and the secret-push PR) that had not merged when this one was opened — #344 has since merged, claiming D-096 for real; D-097 is still in flight.dartway/dartway#338: an iOS build whose Firebase config is issued for another bundle id, or that gets no APNs answer, left the app's push toggle waiting forever, busy, with the setting never saved — worked around in U90 (U-90/u90#98) with the app's own 20 s cap. Owner, 2026-09-26, after review of the first version: option A, an honest status.yes
D-099js/* npm packages get a tier of their own in tool/checks.sh, run inside test/all next to the Dart suites (js_checks, discovered the same way packages() finds a pubspec.yaml — by package.json under js/*/*), each checked with npm ci && npm run check (typecheck, node --test, build). The checks.yml test matrix entry gets Node 22.18.0 (analyze stays Dart-only) — the version Node's .ts type stripping became unflagged, which is what makes node --test run this repo's test files at all; below it, default file discovery matches nothing and node --test prints # tests 0 and exits 0. require_node_floor refuses to run a js/* package below the version named in its own engines.node rather than a constant copied here, and js_checks additionally greps npm run check's output for # tests [1-9], so a suite that ran zero tests for any other reason (a glob that stopped matching, say) fails loudly instead of reading as green — the first version of this decision pinned Node to 22.6 on exactly that unnoticed premise, caught in review of the PR that introduced it.dartway/dartway#329: js/studio-bridge (the JS half of the Studio bridge) had no workflow running npm test at all — tool/checks.sh discovered only pubspec.yaml, so a regression there, including a security check missing from #onWindowMessage, could reach master green with its own golden wire-string tests never having run; the CI job that first tried to fix this ran zero tests and stayed green (job 36243686357), because node --test finding nothing and passing look identical.no
D-100A simple route's extraPathSegment replaces the enum name in the URL rather than prefixing it; the parameterized descriptor's prefix behaviour is untouched. _SimpleRouteDescriptor.pathSegment stopped routing through the shared buildPathSegment helper (which prepends extraPathSegment to whatever core segment it is given) and now returns extraPathSegment ?? routeName directly — buildPathSegment stays, used only by the parameterized descriptor, whose core segment is a parameter pattern (:userId), not a name, so prefixing it cannot double anything. extraPathSegment must not be the empty string either now — an assertion catches it, since '' used to silently produce a route ending in a bare /, invisible to the duplicate-path check. Decided by reading every real caller rather than guessing: a live project's eight .simple() routes with extraPathSegment set (editProfile → 'edit', following → 'following', notificationSettings → 'notifications', languageSettings → 'language', healthConnections → 'health', planPreferences → 'plan', planEditing → 'new', planHistory → 'history') all read as the segment standing in for the name, none as a second word beside it, and its five .parameterized() routes with extraPathSegment set (communityPost, publicProfile, activityLogDay, dishReview, member) are unaffected, confirming the split is real rather than a guess. The framework's own rule that route names are one namespace shared by every zone (the 1.1.2 duplicate-name check) is the reason a project would reach for extraPathSegment on a simple route at all — to give a page a URL word that is not forced to also be its (globally unique) Dart identifier; the duplicate-name error message is corrected to say so. Breaking (3.0.0, not a patch): any project relying on the old, doubled URL of such a route — or of any of its descendants, since fullPath is built from the parent chain and every route under an affected one moves too — needs its stored links updated or redirected, migration note in docs/migrations/. The family moves to 0.21.0-dev.7 in lockstep, since dartway_core_flutter now resolves a dartway_router that builds different URLs under the same name, as #302 did for the router's previous breaking change.dartway/dartway#314: editProfile with extraPathSegment: 'edit' under profile resolved to /profile/edit/editProfile instead of /profile/edit. Caught by review of the first version of this fix: the migration note missed descendant routes, misdescribed DwGoRouterOptions.redirect as running ahead of zone guards (it runs after), used a wrong route count, left the duplicate-name error's own claim uncorrected, allowed an empty extraPathSegment, and didn't move the family version despite changing what dartway_core_flutter resolves.no
D-102sslmode=verify-full with a CA file, closing the gap D-096 left open (dartway/dartway#342). DwDatabaseConfig gains an optional caFile (DW_DATABASE_CA_FILE in fromEnvironment, additive — absent, nothing changes): when set, every connection opens with SslMode.verifyFull and a SecurityContext()..setTrustedCertificates(caFile) instead of SslMode.require — confirmed by reading postgres 3.5.12's own source (ConnectionSettings.securityContext, SecureSocket.secure(..., onBadCertificate: ...)) rather than assumed from its docs: SecurityContext() loads no platform trusted roots unless asked to, so this trusts exactly the one CA named, nothing else, and a certificate from any other authority — including a public one, or the right authority for the wrong host — is refused with BadCertificateException. The choice lives in one place, dwSslMode/dwSecurityContext in dw_connection_pool.dart, called by both DwPooledConnection.open and _DwListener.connect (listen(), which DwJobRunner uses whenever jobWorkers > 0) — a first version left the listener's own, separate ssl ? require : disable untouched, found in review before merging: a LISTEN session cannot be pooled, but skipping the shared choice meant it authenticated unverified even when every pooled connection was verifying against a CA. DW_DATABASE_CA_FILE set together with DW_DATABASE_SSL=false is a contradiction, refused both by fromEnvironment and by an assertion on the constructor itself. On the deploy side: DwStack.databaseCaFileKey is the one source for the key name; the value must be exactly ${DwStack.secretFilesDir}/<name> (the server opens the literal path, so a value merely ending in a declared name but mounted, or not mounted, anywhere else is refused for its directory, found in review — a basename-only check would have missed /etc/ssl/x.pem naming a declared x.pem), and database-reachable's script refuses a CA naming a file not declared under requires.files, or declared but never delivered with secret put-file — the same facts the server itself would fail on, checked before anything connects — then, when valid, bind-mounts that file into the throwaway client and connects with the identical sslmode. A certificate needs a subjectAltName for the connecting host: dart:io/BoringSSL has no fallback to the deprecated CN field the way libpq (and so database-reachable's own psql client) still does, and separately wants extendedKeyUsage: serverAuth — found while building the docker-tagged proof, whose first, CN-only, EKU-less certificate connected under libpq/openssl verify and was refused outright by the server's own driver; documented in docs/5-tooling/deploy.md, not enforced by the check itself, since its pinned Postgres client image carries no openssl to inspect the SAN with. No migration note: purely additive, nothing existing changes shape. Numbered D-102, not D-101: the latter is claimed by a branch in flight that had not merged when this one was opened.Issue #342 (owner), left open by D-096/#325 on purpose: require encrypts but never checks the certificate, so a network position that can intercept the connection can present its own unchallenged. Opus review of PR #357 caught the listener gap (a real, if narrow, authentication-bypass regression) and the basename-only file check before merge.no
D-103tool/release.dart gains --cut and a rate-limit-aware --publish (#337, #313). On a tree where the family (dartway_core_shared, _core_server, _core_flutter, _orm, _client, _generator) carries one shared prerelease (D-086), the plan now says so in one line — cut the release first: family at X.Y.Z-dev.N — instead of listing caret errors that read as broken dependencies (PlainVersion.tryParse returning null for a prerelease is by design, not a broken caret), and refuses (exit 1) rather than reading as a clean, empty answer — a plan-only run that cannot be trusted exits the same 1 a --publish run on the same tree would, having published nothing. --cut (release_cut.dart) does the mechanical half only: on a clean tree, under the pinned SDK, it moves the family's own version and every caret on it — across packages/, template/, example/ and tool/ — from the shared prerelease to its plain form, restricted by the package's own name: field so a satellite whose version text merely happens to match is never touched, dart pub gets wherever a lockfile lives, then runs the ordinary plan so staleVersionsAmong names whichever satellites still need their own bump; it never bumps a satellite itself, since patch/minor/major there is a judgement on that satellite's own CHANGELOG.md, not a text rewrite. Nothing is committed by --cut — a person, or the rest of the same run, looks at the result first. The plan also refuses (new, this decision) when a package it would publish has a CHANGELOG.md whose top ## entry does not name the version being published — the synchronisation law's own item 6, which dart pub publish --force bypasses along with pub's own warning about it; --cut deliberately never writes that heading itself (it is human text), so this is what tells the maintainer to add it, on the family right after a cut as much as on any satellite. Separately, --publish (release_publish.dart) now tells pub.dev's package-created rate limit — matched against the exact refusal text, never guessed at from the exit code alone — apart from an ordinary failure: a short-window answer is retried in place (5 tries, 2 minutes apart); a daily-window answer defers that package and, walking the plan in the order it already publishes in, everything later in it that depends on the deferred package, directly or transitively through as many hops as the plan has, while the rest of the order still goes out. A run that deferred anything, or that a genuine failure cut short, exits non-zero and reports exactly what published, what was deferred and why, and what was never even attempted — a partial release must never read as a clean one, and a failing attempt's own output is printed exactly once, not once live and again in the report. The plan also states up front how many of its entries are first publications, since pub.dev allows at most 12 of those in a day.dartway/dartway#337: the family's between-releases prerelease (D-086) made every plan on master unreadable ("not satisfying the caret" for a caret that in fact resolves), and the cut to plain versions had no tool — done by hand, with 13 satellite bumps found only by simulating it in a scratch worktree. dartway/dartway#313: the rewrite's first publication (#308) hit pub.dev's package-created limit mid-run and --publish stopped dead, leaving independent packages unpublished for hours until a manual re-run — an automatic rate limit is not the same failure as a broken package and should not stop a release the same way. The exit-code, CHANGELOG-refusal, transitive-deferral and double-printed-output points were Opus's review findings on the PR that introduced this (#358), before it merged.yes
D-104A provider identity is a first-class DwIdentityInfo/DwIdentifierChange, and a verified e-mail may link one to an existing account (dartway/dartway#355, #356). dwEnsureExternalAccount stored a provider sign-in's kind as the provider's name (google, apple), but DwIdentifierKind only ever named phone/email and DwAuthStore.identityOf parsed every row's kind with DwIdentifierKind.values.byName — so a provider row threw ArgumentError the moment any reader touched it: listIdentities, listIdentitiesOf, moveIdentities/removeIdentities with kinds: null. Signing in itself never hit the bug (it does not read identities back), which is why it shipped unnoticed until U-90's admin screen rolled its sign-up back with a 500. Fix: kind on DwIdentityInfo and DwIdentifierChange becomes DwIdentifierKind?, both gain provider: String?, and exactly one of the two is ever set (an assertion, not a convention) — DwIdentityInfo.kindName answers provider ?? kind!.name for the one thing both forms share, a lock key. DwAuthStore.identityOf tries DwIdentifierKind.values first and falls back to the stored text as a provider name; every reader and lock key builder (_lockedIdentities, moveIdentities, removeIdentities) uses kindName instead of assuming kind is set. _isProviderName refuses email/phone as a provider's own name, since identityOf's fallback depends on the two namespaces staying disjoint. Breaking, not additive: existing code reading identity.kind.name without a null check no longer compiles, so this is feat!: and moves the family to 0.21.0-dev.8 with a migration note. The wire does not bump for it: dto.identityInfo's encoding is unchanged, the new provider-shaped form is recorded as an additional golden shape rather than a changed one (D-052's own "a new shape is recorded without a bump"), and the framework's one built-in command that answers a DwIdentityInfo — DwConfirmIdentifier — only ever attaches a code identifier, so no installed app meets the new shape from the framework itself; a first version of this change bumped dwProtocolVersion to 3 regardless and was reverted on review; a project that hands its own DwIdentityInfo list to the wire decides for itself. Second, independent fix for #356: DwAuthConfig.linkByVerifiedEmail (default false) — on, the first sign-in of a provider identity whose token proves a verified e-mail (passed explicitly as dwEnsureExternalAccount's verifiedEmail:, read by DwSignInProvidersModule straight off the token's claims rather than fished back out of the registration map by its dw. key) matching an existing email identity attaches the provider identity to that account under both identifiers' advisory locks, taken in the same sorted order _lockedIdentities already used for several locks at once, and fires onIdentifierChanged with a new cause, DwIdentifierChangeCause.linked — distinct from confirmed, since nothing confirmed a code here — instead of onExternalAccountCreated; isNewAccount is false. The match does not require the target email identity to itself be verified: an e-mail sign-in already joins an account whose email identity DwAccountService.ensure attached unverified (a seed, an admin bootstrap) the same way, so a provider's proof of the same address is held to no higher a bar — a first version of this change added that requirement on review and reverted it on a second review, once the parallel with ensure was pointed out: without it, an admin bootstrapped by ensure(email, ...) and signing in with Google for the first time would get a second, rights-less account, exactly the bug this decision otherwise fixes. It lives on DwAuthConfig, not on DwSignInProvider in dartway_auth_providers_server: the policy ("same verified e-mail is the same account") is the project's and provider-agnostic, and dwEnsureExternalAccount is where both the account and the lock already live. One narrowing does hold on its own: linking refuses when the matched account already has a different identity of this same provider (_hasProviderIdentity) — the shape a lapsed custom domain re-registered by someone else would take, an unrelated Google account landing by e-mail on somebody else's account that already has its own Google identity; it does not cover a first sign-in with a provider the account has never used at all, which is why the option stays opt-in. Apple's private-relay address needs no special case, since it simply never matches an existing email identity. Template, toolkit and docs/4-server/auth-identity.md updated; template/'s own UserIdentifier (admin panel) skips a provider identity, since the skeleton offers no provider sign-in to show one for.dartway/dartway#355 (Disregard-Therest, Evgenii Novikov): "In U90 a Google sign-up rolls back with a 500", found wiring Sign in with Google in U-90/u90 on branch u90-google-signin. dartway/dartway#356, same branch: "Continue with Google" with an e-mail that already has an account made a second, empty one; owner decision 26.09.2026, "same verified e-mail = same account". Two rounds of the repository's own automated review on PR #361: the first (non-blocking) raised the unverified-target-identity gap, closed the same day; the second round of owner review reverted the protocol bump and that same gap (the ensure/admin-bootstrap parallel outweighed it) and added the same-provider guard in their place.yes
D-105Analytics reads: reports, a catalog and saved dashboards in the module; the viewer is source in the skeleton's admin panel (#360). DwGetAnalyticsReport counts one event (or every event) over a DwAnalyticsPeriod as events, distinct accounts or distinct installs, with AND-ed property = value filters compared as the JSON value's text, broken down by none, a time bucket in the viewer's calendar (a fixed UTC offset, empty buckets as zero) or a property's first N values plus other — largest first, or by label (numbers numerically, then text, the missing value last: a funnel's steps), other counted by the same metric; one parametrised statement, the aggregate and breakdown chosen from enums. DwGetAnalyticsCatalog lists names and keys seen in a period. dw_analytics_dashboard keeps ordered DwAnalyticsWidgetSpecs (indicator / bar / pie) as jsonb; a pie only for counted events by a property, since distinct people overlap between values. localDays ends at now, and previous moves the period back by the whole days it spans, so a morning is compared with the same morning. DwAnalyticsModule(readAccess:, editAccess:): the project's rules, default closed, anonymous refused at start. dartway_analytics_flutter adds only saveDashboard / deleteDashboard, which re-read DwListAnalyticsDashboards — reports are ordinary dw.request reads, so no second way to read them. The viewer — lib/admin/analytics/ and ui_kit/3_special/charts/ — lives in template/ and example/; charts are plain widgets (bars) and one CustomPaint (the pie ring), no chart package. The skeleton now records events (DwAnalytics plugin) and serves them to admins. Satellites 0.1.0 → 0.2.0. Numbered D-105: D-101 is claimed by a branch in flight, D-104 by provider identities (#361).Owner, 2026-09-27: the viewer is ours and universal, not per project; queries, storage and access in the module, the rule from the project; no widgets in the framework. Dashboards are not live because a module declares no channel rule and the framework knows no roles to write one; the plugin's calls make the refresh impossible to forget. A chart package would put a second design system and a dependency into every created project for two shapes; widgets give tooltips, semantics and theme colours for free. Funnels as ordered sequences, cohorts and export stay SQL until a project needs them.no
D-106DwAppServer.handlers and .jobs enumerate the project's own calls and jobs completely: features' and modules' together (#366). Before, both getters read features only; start separately spliced in authService.handlers(), the file store's and every module's, so the getters undercounted what the running server actually answered — a project's own access-matrix test walking server.handlers (Molodey's roles_acceptance_test.dart) never saw a module's calls (DwAnalyticsModule's report/catalog/dashboards), and widening a module's readAccess left it green. channels and routes stay features-only: DwServerModule exposes neither. start now reads handlers/jobs once instead of appending modules a second time, so nothing registers twice; validate()'s feature/module collision check moved to new private _featureHandlers/_featureJobs so a module's own calls and dw.<namespace>. jobs are not compared against themselves. Not breaking in type (same getters, same element type); their returned lists widen, which is why this is a decision and not a plain bugfix. No migration note: nothing asks a project to change its own code, only to see more from a call it already had.Silent per its silent label: nothing failed, an access matrix just quietly covered less than it looked like it covered. The framework's own auth and file calls are deliberately still excluded from both getters — their access rule is the framework's, not the project's, and their handler objects exist only once start has built them (documented on the getters).no
D-107New satellite dartway_media_flutter (dw.plugins.media): the plugin owns the sessions, one DwMediaController API over video_player and just_audio, and every behaviour a DwMediaConfig setting with a per-session DwMediaOpenOptions override (dartway/dartway#372). Mechanism only: no chewie, no colours, no text, no controls — the default controls are source in example/'s ui_kit/3_special/media/, taught by the new dartway-media skill; the template gets nothing, so a project that plays nothing downloads no player. Shape: DwMedia.open returns a DwMediaSession held by DwMediaSessionManager, not by a widget (one active item under singleActiveItem, sessions survive route changes); DwMediaSource.resolve is re-called on every load and retry, and retry() returns to the failed position; state is ValueListenable (session.playback follows the queue, queue, isFullscreen, minimized) rather than riverpod providers, because a session exists only from open(); fullscreen is the package's own DwMediaFullscreenRoute, pushed by DwMediaFullscreenHost whenever isFullscreen turns true, so enterFullscreen(), autoEnterFullscreenOnPlay and keepFullscreenAcrossItems are one path; DwMiniPlayerHost is mechanism with an onExpand callback. Real playback, not a position: both engines report a seek to the end as completed (video_player's isCompleted on any seek to the duration; just_audio on Android after a paused seek, possibly after the seek returns), and video_player reports isPlaying before the platform answers play(); so a tick counts only when the engine plays, no seek of the controller is in flight and the position moved past the play start or the last seek, and onStarted, onProgress, the periodic save and reachedEndTolerance read only such ticks while onReachedEnd needs real playback since the last seek — a first draft guarded one synchronous update per seek, which a mute after a scrub to the end still counted. onCompleted stays the threshold (a seek counts): "watched enough" and "played to the end" are two callbacks, not one tunable. Countdown is after the end (autoplayCountdownDuration, cancelAutoplay), not the last seconds of the item. video_player's own background observer is switched off (allowBackgroundPlayback: true): it pauses and resumes by itself and would override pauseVideoInBackground both ways. Resume saves on saveOnPause / saveOnBackground / saveOnDispose separately (a first draft had one flag). Web: a video starts muted (webMutedStart) and stays so until the person unmutes, since a browser pauses a video unmuted by script without a gesture; rememberSound is plugin-wide; benign browser races (AbortError, an element gone) and a refused play() are not error states; an item's engine is disposed only after the frame that removed its view. Test support in testing.dart: DwFakeVideoPlayerPlatform / DwFakeJustAudioPlatform with install(), and dwSettleMedia, because just_audio and a broadcast stream cancel complete futures in the root zone that a widget test's fake clock never runs — which makes flutter_test a dependency of the package, imported only by testing.dart. After the first review round the shape tightened: the engine (DwMediaController) and the fullscreen route are not exported — everything goes through the session, and DwMediaFullscreenHost is the one way into fullscreen, which takes effect only while a host is mounted (from the mini-player a fullscreen request would leave a flag with nothing showing it); one item, one engine: open() for an item a live session stands on returns that session, opening without autoplay never takes active from a playing session, a session the mini-player's close only hid ends when another opens, and with miniPlayer: false the page leaving follows onLeaveWithoutMiniPlayer (pause by default) instead of leaving playback headless; the background rule stops buffering, pending-play and counting-down items too, and nothing starts in the background; a seek clears a real end for autoplay every time, not once per item; controls visibility is the session's (controlsVisible, controlsAutoHideDelay), not a timer in the app; setSpeed accepts only a listed speed; benign browser errors are recognised on the web only and by exact DOMException name (AbortError, NotAllowedError), every other failure is an error state; a retry shows loading at once and joins a retry already running; fixed policy became settings (autoplayCountdownTick, fullscreenTransitionDuration/Builder, fallbackAspectRatio, miniPlayerSnapEdges/Threshold). The test fakes were written from the platform interfaces alone. A second review round: a fullscreen request made before any host is mounted (autoplay on open, the page's initState) waits and the first host honours it — only a request from the mini-player is dropped; reuse honours the new open — callbacks and settings replaced (the start speed, the web's muted start and video_player's iOS display-sleep option are load-time and stay), a different queue replaced around the same item with its engine kept, autoplayOnOpen playing a paused or hidden session — so "open" means what the caller asked for and a gone page's callbacks go quiet; a session's teardown writes its notifiers once the tree is unlocked, so onLeaveWithoutMiniPlayer: stop from State.dispose is safe; loadTimeout (30 s) turns a load that never settles into an error, so a joined retry cannot hang; defaultSpeed must be one of a non-empty speeds, asserted where settings resolve. Satellite 0.1.0, no wire change, no migration note; added to the workspace, example/, docs/3-flutter/plugins.md and the root CLAUDE.md map.Owner decision 2026-09-28: one player for every product instead of one per project; the owner's comment on the issue the same day made every behaviour optional and tunable, each with a test of its changed state and a row in the docs table (docs/3-flutter/media.md). The guards were taken from the two projects' own lessons, re-read against video_player 2.14 and just_audio 0.10 sources — in 0.10 a mid-track failure surfaces on errorStream, the state and event streams swallow it.no