Skip to main content

02 - Decisions

A short log of the decisions that shape Sinew. An ADR is never edited after it's accepted: a later decision supersedes it with a new ADR. When a base contract changes, its ADR is added here before the notes are edited.

ADR-1 Sinew ships mechanisms; apps ship configuration​

  • Context: every app rewrote the same plumbing, and what differed was only configuration (base URLs, token storage, module codes, envelope shapes).
  • Decision: Sinew provides the mechanisms. The app plugs configuration in through constructor parameters and small interfaces (SinewConfig, TokenSource, CrashReporter, ErrorBodyReader, Localizer), and its own envelopes and named stores.
  • Consequences: no app forks Sinew to configure it. Every interface the app implements is small and has a fake in sinew_testing.

ADR-2 Separate packages, lockstep versions, no umbrella​

  • Context: apps need only some packages. One big package would pull an HTTP client, crypto and secure storage into an app that only wants paging.
  • Decision: every package is published on its own (pub sinew_*, Maven com.srctool.sinew:sinew-*), released together at one version number, with a Kotlin BOM. There's no umbrella package.
  • Consequences: any set of Sinew packages at one version fits together. Flutter apps add a direct dependency on packages whose types they import.

ADR-3 No code generation, hand-written sealed types​

  • Context: code generation in a library forces its tool and its version on every app.
  • Decision: ViewState, Result, PagingState and the envelope bases are hand-written. Kotlin uses kotlin.Result; Flutter has Sinew's own Result.
  • Consequences: apps may still generate their own DTO parsing and API clients.

ADR-4 Abstract envelope bases only​

  • Context: backends wrap payloads differently, and one backend can have several paged shapes. Default envelopes with fixed JSON keys rarely fit.
  • Decision: Sinew ships Envelope<R> (with defaults for backends without a success flag) and the abstract ObjectEnvelope, ListEnvelope, PagedEnvelope and VoidEnvelope. Every app declares its own envelopes; PagedEnvelope.meta() maps each backend's page keys.
  • Consequences: mapping every item stays automatic. Tests use JSON fixtures in the app's own shape.

ADR-5 The HTTP library is exposed on purpose​

  • Context: hiding Dio or Ktor would mean a second HTTP API to learn and maintain.
  • Decision: sinew_network builds and configures a Dio or Ktor client. API clients are written directly on it.
  • Consequences: Flutter apps add dio and retrofit as direct dependencies.

ADR-6 One error model, one classification, codes everywhere​

  • Context: errors leaked as library exceptions, got swallowed, or couldn't be traced from a support report.
  • Decision: failures are translated once, where they happen, into typed AppExceptions with a module-layer-function-type code. Each layer hands a Result upward, and lower-layer exceptions pass through unchanged. The classification is the same on both platforms: descriptive names with short codes. success: false is ApiErrorException; ServerException is only for 5xx. retryWithBackoff returns the last real failure, so there's no "retry exceeded" type.
  • Consequences: view models never try/catch. Every displayed message ends with its code.

ADR-7 The error-model base lives in the leaf package​

  • Context: ViewState and Result carry an AppException, but the exception package depends on models: a cycle.
  • Decision: AppException, ExceptionLayer, ErrorMessage and the plain EnvelopeDataMissing live in sinew_models. The concrete types, handlers and guards live in sinew_exception.
  • Consequences: envelopes throw a plain error, which the guards map to ParseException.

ADR-8 One documented global: the crash reporter​

  • Context: guards are plain functions called from every repository. Passing a reporter into each call would touch every call site.
  • Decision: CrashReporter.install(reporter) once at startup, none by default. It's the only global in Sinew.
  • Consequences: tests install a recording reporter and reset it.

ADR-9 Two message sources; local messages localized at render time​

  • Context: messages built as text in the data layer froze the language in page state, and backends already localize their own messages.
  • Decision: backend text is shown as sent, and the backend localizes it from Accept-Language. Locally detected failures carry a key with arguments, resolved when the page renders through the app's Localizer. sinew_l10n ships English and Indonesian. State keeps the exception, never pre-rendered text.
  • Consequences: a language switch updates errors already on screen. Status errors use server text when readable, else the local key.

ADR-10 Pager owns the paging rules​

  • Context: paging rules written by hand in each view model drifted, and caused stale results, duplicates and wrong retries.
  • Decision: Pager<T> holds no UI state and writes PagingState back through the view model:
    • Initial runs at most once per query; search is Initial with a new query.
    • The first page is fetched directly.
    • Stale responses are dropped by request order.
    • The requested page number wins over the reported one.
    • itemKey de-duplication is opt-in.
    • An empty first page is Done.
  • Consequences: a list page only says how to fetch one page.

ADR-11 ViewState has no Idle; side effects are effects​

  • Context: Idle was a workaround from before effects: a slice left at Failed re-fired a state-driven snackbar whenever other state changed.
  • Decision: ViewState is Loading | Done | Failed, matching Riverpod's AsyncValue. Slices loaded on open start as Loading, and several load in parallel. Action slices are Boolean, with the outcome as an effect. On-demand slices are nullable. A page never triggers a side effect from a state change. On Kotlin, the opening load is guarded by a private flag in the view model.
  • Consequences: the AsyncValue conversions are exact.

ADR-12 Framework-neutral presentation, thin adapters​

  • Context: apps run on Riverpod or AndroidX ViewModel, but the page pattern is the same.
  • Decision: sinew_presentation (StateEffectHandler, EventActionHandler, EffectEmitter) has no framework dependency. sinew_riverpod holds the mixins, hooks and AsyncValue conversions. sinew_riverpod_providers holds the ready providers. sinew-viewmodel holds the ViewModel bases and ObserveEffects. There's no Koin adapter: its wiring is documented.
  • Consequences: Riverpod apps that want only the mixins don't pull in network and storage.

ADR-13 Token refresh is single-flight, with a three-way outcome​

  • Context: parallel refreshes race with rotating refresh tokens, and a refresh that failed because the network was down used to sign users out.
  • Decision: one refresh at a time. Replays are marked, and a token is attached only to the client's own host. refresh() returns Refreshed | Rejected | Unavailable: only Rejected ends the session. There's no automatic retry in the client.
  • Consequences: a backend outage never signs a user out.

ADR-14 Vetted crypto per platform, behind one interface​

  • Context: hand-rolled crypto is a common source of bugs, and no single crypto library covers Android, iOS, web and Dart.
  • Decision: FieldCipher (AES-256-GCM, associated data, rotatable keys) and BiometricVault (protected by the biometric key itself) are built on vetted libraries: Tink on Android/JVM, the platform's own crypto elsewhere. Sinew writes no primitives. Desktop and web are supported at a weaker, documented level.
  • Consequences: each platform's implementation is tested on that platform.

ADR-15 Storage: typed stores, one guard name, no database​

  • Context: secrets ended up in plain preferences, and storage failures crashed apps.
  • Decision: SecureStore holds small secrets (cleared on an iOS reinstall), and KeyValueStore holds preferences. processStorageCall (same name on both platforms) takes a read/write access. pagedQuery works with any database. Sinew ships no database.
  • Consequences: a value that can't be read is absent, never a crash.

ADR-16 Platforms and targets​

  • Context: Sinew should run wherever its sibling Camouflage runs.
  • Decision: Kotlin targets Android, iOS, JVM desktop and wasmJs (commonMain), usable from a plain Android app. Flutter targets its six platforms where the underlying plugin allows. An unsupported platform compiles and throws a clear UnsupportedError at runtime, and each package lists its platforms.
  • Consequences: the CI matrix covers every target from S0.

ADR-17 DevTools: split, compiled out of production, redacted when recorded​

  • Context: in-app developer tools are vital for QA, and dangerous when they ship to users or record secrets.
  • Decision:
    • Packages: sinew_devtools (capture, no UI) and sinew_devtools_ui (its own theme and back stack); a Kotlin no-op artifact.
    • Gating: on in debug builds and non-production release builds, compiled out of production release and profile builds, and checked in CI.
    • Data safety: redaction happens when recording, at any JSON depth. Secrets are masked to their last four characters and never copied. Fault rules live in memory only. Native-plugin inspectors are separate packages.
    • It ships as 1.1.
  • Consequences: apps add their own sections. Sinew's section comes first.

ADR-18 Waterfall docs, Kotlin first per milestone​

  • Context: the same design has to land on two platforms without drift.
  • Decision: every note is written and reviewed before code. Each milestone is built in Kotlin, then Flutter. The docs stand on their own and are published on Docusaurus.
  • Consequences: a design change is an ADR here before it's a code change.

ADR-19 A session epoch voids a refresh that outlives sign-out​

  • Context: an independent review found that a refresh finishing after sign-out stored fresh tokens, silently signing the user back in.
  • Decision: TokenSource.clear() must void any running refresh. Sinew's ready SessionTokenSource(RefreshTokenStore, RefreshCall) does it with a session epoch: clear() bumps it, and a refresh stores its tokens only if the epoch is unchanged, checked and written under one lock. The auth layer also replays a 401 sent with an older token without refreshing again, and runs the refresh in a scope the client owns.
  • Consequences: apps implement only the backend call and the token store. A custom TokenSource must keep the same guarantee. Supersedes nothing; extends ADR-13.

ADR-20 Redaction matches normalized names and parts​

  • Context: exact-name matching missed camelCase fields (refreshToken, newPassword) and API-key headers.
  • Decision: key names are normalized (lowercase, without _, -, .) and redacted when they contain a sensitive part (token, password, secret, otp, pin, apikey…). Every defaultHeaders name is redacted automatically. Extends ADR-17.
  • Consequences: some harmless fields with "token" in the name are masked too. That's the safe direction.

ADR-21 Every dependency used in code is in the graph​

  • Context: the review found edges used in code but missing from the graph (adapters → exception, glue → l10n, providers → network and storage, devtools → network and storage).
  • Decision: the graph in Architecture lists them. Devtools depend on network and storage to provide their hooks; network and storage only expose hook points and never depend on devtools.
  • Consequences: the CI graph check enforces the full graph.

ADR-22 sinew_l10n is the one generated package, and its output is committed​

  • Context: ADR-3 says no code generation, but localization on Flutter is generated from ARB files.
  • Decision: sinew_l10n's localizations are generated and committed. Consumers never run a generator for Sinew. Amends ADR-3.
  • Consequences: a translation change regenerates and commits the output in the same change.

ADR-23 Biometric items follow the reinstall rule; nonces come from secure random​

  • Context: on iOS, the biometric vault's Keychain item survived a reinstall. Separately, "Sinew never chooses a nonce" couldn't hold where a crypto library takes the nonce as input.
  • Decision: BiometricVault.create(clearOnReinstall = true) deletes its item on the first use after an install. Every nonce is 96 bits from a secure random source, fresh per value, never a counter or a constant. Amends ADR-14.
  • Consequences: a reinstalled app never unlocks a previous install's session.