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_*, Mavencom.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,PagingStateand the envelope bases are hand-written. Kotlin useskotlin.Result; Flutter has Sinew's ownResult. - 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 abstractObjectEnvelope,ListEnvelope,PagedEnvelopeandVoidEnvelope. 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_networkbuilds and configures a Dio or Ktor client. API clients are written directly on it. - Consequences: Flutter apps add
dioandretrofitas 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 amodule-layer-function-typecode. Each layer hands aResultupward, and lower-layer exceptions pass through unchanged. The classification is the same on both platforms: descriptive names with short codes.success: falseisApiErrorException;ServerExceptionis only for 5xx.retryWithBackoffreturns 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:
ViewStateandResultcarry anAppException, but the exception package depends on models: a cycle. - Decision:
AppException,ExceptionLayer,ErrorMessageand the plainEnvelopeDataMissinglive insinew_models. The concrete types, handlers and guards live insinew_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,noneby 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'sLocalizer.sinew_l10nships 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 writesPagingStateback through the view model:Initialruns at most once per query; search isInitialwith 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.
itemKeyde-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:
Idlewas a workaround from before effects: a slice left atFailedre-fired a state-driven snackbar whenever other state changed. - Decision:
ViewStateisLoading | Done | Failed, matching Riverpod'sAsyncValue. Slices loaded on open start asLoading, and several load in parallel. Action slices areBoolean, 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
AsyncValueconversions 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_riverpodholds the mixins, hooks andAsyncValueconversions.sinew_riverpod_providersholds the ready providers.sinew-viewmodelholds the ViewModel bases andObserveEffects. 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()returnsRefreshed | Rejected | Unavailable: onlyRejectedends 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) andBiometricVault(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:
SecureStoreholds small secrets (cleared on an iOS reinstall), andKeyValueStoreholds preferences.processStorageCall(same name on both platforms) takes a read/writeaccess.pagedQueryworks 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 clearUnsupportedErrorat 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) andsinew_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.
- Packages:
- 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 readySessionTokenSource(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
TokenSourcemust 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…). EverydefaultHeadersname 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.