Assumptions log
Every decision taken in this design without asking, with the reason, so each can be confirmed or reversed quickly.
Updated: 29 October 2026
The product owner asked for a complete design without questions. These are the assumptions that filled the gaps. Each has a default the design is built on; the ones that matter most were also on the questions page. Every assumption here is built, so each is decided; on 2026-10-30 the product owner confirmed the build as the answer.
| # | Assumption | Reason | Reversible? |
|---|---|---|---|
| A1 | Primary domain is incico.eu, e-mail addresses @incico.eu | The v2 website’s production .env used www.incico.eu; incico.app appeared only in templates and placeholders | Yes, string replace in i18n and Auth config |
| A2 | A new Supabase project per environment (incico-staging, incico) in eu-west-1 | Same region and organisation as Saymail; EU data residency; separate from Saymail’s data | Yes before phase 1 |
| A3 | Sign-in: e-mail (password and magic link), Google, Apple. Microsoft prepared, disabled. Meta dropped. | v2 listed Google, Microsoft, Apple, Meta as placeholders; Apple is mandatory when Google is offered on iOS; Meta login adds review burden and little value for a contacts app | Providers are configuration |
| A4 | No ads, no supporter purchase at launch | v1’s privacy policy made tracking for ads mandatory, which conflicts with the privacy-first positioning and complicates store review; simpler v3.0 | Yes; entitlements hook reserved |
| A5 | Connected services at launch: device contacts (iOS and Android) and Google Contacts; Microsoft behind a flag; Apple iCloud via device contacts only; no Meta | Covers most users; device contacts need no OAuth; Microsoft Graph is a known quantity from Saymail | Yes |
| A6 | Multiple profiles per account kept (main + managed) | Present in v1 and v2 and in the v2 marketing carousel (“Add multiple profiles for parents and children”) | Removing simplifies phase 3 |
| A7 | Family: a simple relation label (parent, child, partner, sibling) on a connection; no auto-connect across a family tree; no tree view | v1’s auto-connect created connections the user had not asked for; v2 kept only a “nuclear family” flag | Yes |
| A8 | Languages: English and Dutch in apps and website; docs English | v2 shipped en + nl; Saymail tooling supports de later | Yes |
| A9 | Application id nl.sefaja.incico | Matches Saymail’s nl.sefaja.saymail and the Dutch company; v1/v2 used com.sefaja.incico and an existing App Store record 6505110736; reusing the old id would keep store history and reviews but ties v3 to v1’s leaked signing material on Android | Must be decided before phase 2; changing later means a new store listing |
| A10 | Phone numbers unverified at launch; e-mails verified with a 6-digit code | SMS costs money and needs a provider contract; e-mail verification is free through Supabase and the existing sender | Yes |
| A11 | No web app; the website hosts only account deletion and data export | Both stores require a web deletion path; a web app is out of scope | Yes |
| A12 | PostHog EU, typed events, opt-in at onboarding; no Firebase Analytics | Saymail’s pattern and tests exist; keeps the privacy story simple | Yes |
| A13 | Tablets get two-pane layouts following Saymail’s tablet specification | The owner asked for tablets explicitly; Saymail’s rules are proven | Layout classes are one function |
| A14 | Monorepo IncicoV3 with web/, supabase/, mobile/, docs/, tools/ | Mirrors Saymail; the Supabase folder moves to the root because the apps are its main client | Yes |
| A15 | Git: main + develop + typed branches, merge-based, test.md checklist | Saymail’s git-strategy.md | Yes |
| A16 | Minimum OS: Android 8.1 (API 27), iOS 17 | Saymail uses API 27; iOS 17 covers all devices from iPhone XS on and allows @Observable | Yes |
| A17 | Backups purged within 30 days after deletion | v1 said three months; 30 days is Supabase’s PITR window on the plan in use and a better promise | Yes |
| A18 | Up to 4 photos per profile, 1024 px, EXIF stripped, private bucket | v1 and v2 limit of 4; EXIF strip removes GPS | Yes |
| A19 | Android release builds obfuscated with R8 and a mapping upload | Reverses Saymail’s -dontobfuscate; the reason (unreadable crash reports) no longer holds because Play and PostHog symbolicate | Yes |
| A20 | Search is exact-match on hashed e-mail/phone and whole words of searchable names, rate-limited | Prevents enumeration; v1 allowed 3-character prefixes over plaintext | Yes |
| A21 | Sync with external services runs on the device, tokens never on the server | Removes v1’s plaintext token storage and the OAuth bridge service | Yes |
| A22 | Notifications: push carries only kind and id; details fetched on demand | Prevents PII in push logs of Apple/Google | Yes |
| A23 | Design tokens come from the v1 logo: navy #0f2453 primary, cyan #009fc5 accent, circle colours pink/cyan/orange/green from the figures; Inter on the website, Hanken Grotesk + JetBrains Mono in the apps as in Saymail | The logo is the one brand element users already know; v2’s blue/gold palette was never released (palette and app fonts superseded by A100, 2026-10-17) | Yes |
| A24 | Cross-platform version file mobile/version.json starting at 3.0.0 | Prevents the version drift Saymail has | Yes |
| A25 | v1’s Azure backend and Duende IdentityServer are retired; v1 users must re-register | v1 had few users; migrating identities out of ASP.NET Identity into Supabase Auth is possible (password hashes are bcrypt-compatible via import) but not worth the risk for the user count | Migration script could be added |
| A26 | Existing v1 App Store / Play listings are replaced by v3 under the chosen id; v1 is unpublished | v1 has a live TLS-validation defect | Depends on A9 |
| A27 | A profile is a person, not a persona. Different names for different contexts (work name, gaming handle, full name) are multiple names on one profile, each shared with its own circles. Managed profiles exist only for other people (children, dependants). | v2 mixed both readings (“Family Profiles” header versus onboarding copy about personas); one meaning keeps the data model and the sharing rules simple | Yes, but affects the whole profile model |
| A28 | One canonical validation set, enforced server-side and mirrored client-side: names 1..100 Unicode letters, spaces, hyphens, apostrophes, periods; phones must parse with libphonenumber; e-mails RFC-style and ≤ 254; addresses require country and city; dates require day and month; ≤ 4 photos. Every category is optional (no “at least one” rule). | v2 had three inconsistent editor families and no address validation; v2’s validateName regex rejected accented names | Yes |
| A29 | Circle membership of a name is multi-select (a name can be shared with several circles); v2’s onboarding made names exclusive to one circle and forced every circle to be covered by a name | The exclusivity existed only to guarantee each circle sees some name; v3 guarantees that by always sharing the default name | Yes |
| A30 | Search results show only the found person’s default name and avatar, never the matched e-mail or phone | v2 showed e-mail and phone in results, which leaks the very details the person may not have shared | Yes |
Taken during the phase 1 build
These were decided while writing the backend, where the design said what had to be true but not how. Each is in the migrations today.
| # | Assumption | Reason | Reversible? |
|---|---|---|---|
| A31 | update is granted column by column rather than per table, so a write to a column the database owns fails with 42501 before any policy runs | RLS can say which rows a client may change but not which columns, and is_verified, profiles.version, accounts.deleted_at and connections.circle_id are all proof or bookkeeping rather than preferences | Yes, one grant per table, but widening one needs a reason |
| A32 | An e-mail produces search tokens only once is_verified is true; a phone number does not need that proof | Anyone can type anyone’s address, and an unverified one in the index would let an impostor be found under somebody else’s name. Phones follow A10: SMS verification is out of scope, so requiring it would make phone search useless | Yes, one line in derive_search_tokens |
| A33 | Name tokens are the lower-cased words of a searchable name, split on everything that is not a letter or a digit, keeping words of 3 to 50 characters | “Anne-Marie O’Hara” has to be findable by “anne”, “marie” or “hara” rather than by the punctuation somebody guessed. Two-letter words (“de”, “van”) match half the country and would cost the all-words rule its selectivity | Yes; the bounds are in one function and mirrored in search_profiles |
| A34 | snapshot_hash is taken over the snapshot without its version key | The version changes on every refresh by definition, so hashing it would make every edit look like a change to every contact and notify people about details they cannot see | No, not without re-hashing every stored snapshot |
| A35 | Only a pending invitation pins its sharing circle; an answered one loses the reference (on delete set null) when the circle is deleted | An invitation Ben declined last year must not stop Anna from ever tidying that circle away, and a pending one genuinely still needs it | Yes |
| A36 | A data export replaces the previous archive instead of keeping a history | An earlier export is the same personal data sitting behind a link that may still be in somebody’s inbox; keeping several multiplies the exposure for no benefit | Yes |
| A37 | pg_net’s request queue stays readable by anon and authenticated at the database level, and the webhook secret with it | The grants are owned by supabase_admin and a migration cannot revoke them. The queue is unreachable through the API because [api] schemas lists only public and graphql_public; the control is that net is never added to that list | Only by Supabase changing the extension’s grants |
Taken during the phase 2 build
These were decided while building the app skeletons, where the design said what had to be true but not how. Each is in the code today.
| # | Assumption | Reason | Reversible? |
|---|---|---|---|
| A38 | Both apps use the vendor backend SDKs, supabase-kt and supabase-swift, instead of Saymail’s hand-rolled REST layer | The vendor SDKs give Auth, PostgREST and Realtime clients with session handling and typed errors out of the box; Saymail’s hand-rolled REST client predates them and would only be re-justified by a limitation neither SDK has shown | Yes, but touches every backend call site |
| A39 | A blank INCICO_SUPABASE_URL/INCICO_SUPABASE_ANON_KEY still builds and runs, showing a “This build is not configured” screen instead of failing the build; neither value has a repository default; CI supplies real values from repository secrets | Unlike Saymail, Incico’s repository never carries even a staging default, so a config mistake fails loudly at run time rather than silently pointing a build at the wrong project | Yes |
| A40 | Google and Apple sign-in buttons render only for the providers listed in the build value INCICO_SOCIAL_PROVIDERS (comma list, default empty) | The Google and Apple provider configuration does not exist yet on the Supabase project; showing a button that cannot complete a sign-in would be worse than hiding it. The code paths are complete and unit-tested against fakes regardless | Yes, add the providers to the list once configured |
| A41 | The e-mail/password flow always tries signInWithPassword first; only on an invalid_credentials response does it offer to create an account with that e-mail, then confirms the password, calls signUp, and verifies the six-digit code | Supabase never reveals whether an e-mail already has an account, so the app cannot ask “sign in or sign up” up front without leaking that information itself | Yes |
| A42 | The delta fetch pages by a keyset cursor (updated_at, id) per table, not a single timestamp | A timestamp-only cursor can skip or repeat rows when several rows share the same updated_at; keeping the last row’s id as well makes the ordering total and the paging exact | No, without migrating every stored cursor |
Taken during the phase 3 build
The product owner’s instruction of 2026-09-13 was to continue through all phases without stopping, so the open questions phase 3 needed answered are settled here and can be reversed later.
| # | Assumption | Reason | Reversible? |
|---|---|---|---|
| A43 | Q3, Q6, Q7, Q10 and Q19 proceed on their documented defaults: e-mail, Google and Apple sign-in; managed profiles kept; phones unverified; four photos of 1024 px with EXIF stripped | Each default is already what the design, the schema and phase 1’s migrations assume; choosing differently now would be a change, not a decision | Yes, each is one feature |
| A44 | A managed profile carries profiles.managed_relation (child, parent, partner, other; null on the main profile), the label chosen in “Add profile” | The relationship is what the user typed the profile in as, so it belongs on the profile rather than being re-derived from a connection that may not exist yet. Display-only in phase 3, so nothing depends on its accuracy | Yes, one column |
| A45 | The documented per-profile limits are database triggers like limit_photos: 20 names, 20 phones, 20 e-mails, 10 addresses, 30 dates, raising the new P0001 code too_many_details | A limit the client alone holds is a limit two devices can walk past together. One function, limit_details(), locks the profile row before counting, exactly as the photo limit already does | Yes, the numbers are trigger arguments |
| A46 | delete_profile(p_profile) sends contact_removed to every connection of a managed profile and then deletes it; the plain delete on profiles is revoked. replace_detail_type(p_type, p_replacement) reassigns every detail and deletes the type in one transaction. validate_detail_row also enforces year_circle_ids ⊆ daymonth_circle_ids (The contact_removed notification is dropped by A102, 2026-10-29.) | These close the known gap phase 1 recorded. All three cross rows a client cannot hold in one transaction, and the first crosses accounts | The revoked delete grant is the only hard part to undo |
| A47 | Address field order per country comes from mobile/design/address-formats.json (NL, BE, DE, GB, US, default), transcribed into both apps, with docs/vectors/address-format.json pinning the rendering. The Dutch postcode + house number suggestion uses the keyless PDOK Locatieserver (api.pdok.nl/bzk/locatieserver/search/v3_1/free), best effort, from the device | One file beats two hand-kept tables that drift. PDOK is the official Dutch open data service, needs no key and no contract, and a failed lookup costs nothing because it was only ever a suggestion | Yes; another provider is one function |
| A48 | Phone parsing: Android uses libphonenumber-android; iOS uses a small parser over mobile/design/phone-metadata.json, generated by tools/gen-phone-metadata.mjs from libphonenumber-js. docs/vectors/phone-format.json pins both to the same 50 outputs | iOS has no maintained Swift port worth adding as a dependency, and the data is the hard part, not the parser. Generating the vectors from the same library makes disagreement a test failure rather than a bug report | Yes; a Swift package could replace the parser and keep the vectors |
| A49 | Images: Android displays photos with Coil 3; iOS uses a small file-backed loader over the Supabase Storage download. Upload is a square crop at 1024 px, JPEG quality 85, re-encoded on device (which drops EXIF), at <profile_id>/<photo_id>.jpg; the photos row is inserted after the upload succeeds and the object is removed when the insert fails | Re-encoding is the EXIF strip, so there is no separate step that could be skipped. Inserting the row last means a failed upload leaves no row pointing at nothing; the reverse would need a sweeper | Yes |
| A50 | Telemetry uses no vendor SDK: a minimal Telemetry client per platform posts batches to PostHog EU /batch with the key from INCICO_POSTHOG_KEY (blank disables), only after consent (default off, stored in accounts.telemetry_consent). Phase 3 events: onboarding_completed and detail_edited | The SDKs bring autocapture, session replay and lifecycle events that would have to be switched off one by one and could return with an upgrade. An allow-list is only an allow-list when there is no other path to the network | Yes, but the SDK would reopen that question |
| A51 | Onboarding runs steps 2.1, 2.2.1–2.2.7, 2.4 and 2.5; 2.3.x (connected services) is skipped automatically until phase 5. Every step saves on Continue, accounts.onboarding_completed flips at 2.5, and an incomplete profile shows the “Your profile is incomplete” banner with a “Finish” action that resumes at the first incomplete step | The connected-services steps have nothing to connect to before phase 5, and a step that can only be skipped is worse than a step that is not shown. Saving per step is what makes resuming possible at all | Yes; 2.3.x returns with phase 5 |
| A52 | Doc corrections: the e-mail verification code is valid 15 minutes (app/12 said 10), and the photo system types are Personal and Work (app/12 said Profile / Family / Work) | Both were already decided elsewhere: the edge function and platform/32 say 15 minutes, and migration 20260912100100 seeds Personal and Work. The docs were the outdated copy | Yes |
| A53 | Phone golden vectors (docs/vectors/phone-format.json) bind e164, valid and type on both platforms always; the national form must match exactly for valid numbers; the international form is compared as +<country code> followed by the digits, ignoring spacing | Java libphonenumber and libphonenumber-js disagree on the grouping of a few international forms while agreeing on every digit; pinning the spacing would fail a test for a difference no user can see | Yes; tighten the comparison when the libraries converge |
| A54 | Photos are capped at 1024 px on the shorter side and never upscaled: a smaller source is uploaded at its own size after the square crop and re-encode | Upscaling adds bytes and blur and no information; the cap is a ceiling, not a target. Both platforms follow the same rule so a snapshot renders the same photo | Yes, one constant |
| A55 | Right after ensure_main_profile or a managed profile is created, the app forces a delta fetch before the first data step, so the seeded detail types and circles (and the trigger-filled display_name) are in the cache when the first editor opens | The seed rows are written by triggers on the server; without a fetch the first editor would show no types and no circles to pick from | Yes, it is one extra fetch |
| A56 | Push coalescing is what phase 1 built: insert_notification drops a contact_updated/circle_changed while an unread one exists for the same connection, and push-fanout sends one push per row. app/16 is corrected: the 500 ms and 60 s windows are removed; a timed window stays deferred “once there is traffic to measure” (phase 1 note) | Neither window exists in code; the unread suppression already caps a burst at one push per connection and kind. A timer in the database or the function adds a scheduler for no measured benefit | Yes, the doc paragraph and one function |
| A57 | One push per notification row on both platforms: Android receives a data-only FCM message (kind, id) and renders the localized notification itself in FirebaseMessagingService, then runs a delta fetch; iOS receives one APNs alert with content-available: 1 so the app refreshes before the tap. No second “silent” push | A data-only message gives Android the locale, channel and grouping control the docs describe; an alert with content-available is what Apple allows for a visible push that also wakes the app. Two pushes per row would double the work for the same result | Yes, in push-fanout |
| A58 | expire_invitations() runs hourly through pg_cron (cron.schedule('expire-invitations', '5 * * * *', …)) in a phase 4 migration; the local stack has the extension | The roadmap assigned it to phase 4; an hourly tick is enough for a 30-day expiry | Yes |
| A59 | Device registration: after the OS permission is granted (or at sign-in when it already is), the app upserts its devices row (platform, push_token, app_version, locale, last_seen_at) by token; a token refresh updates it; sign-out and session expiry delete the row before the local wipe. The row is not mirrored by the sync engine; the app remembers only its own device id and token so it can update and delete that row | The table and its eviction trigger exist; deleting on sign-out is what stops pushes reaching a signed-out phone | Yes |
| A60 | Android initialises Firebase from build-time values (INCICO_FCM_PROJECT_ID, INCICO_FCM_APPLICATION_ID, INCICO_FCM_API_KEY, INCICO_FCM_SENDER_ID, already in .env.example) with FirebaseOptions, no google-services plugin; a blank project id disables push registration cleanly. iOS registers with APNs through UIApplicationDelegateAdaptor, entitlement aps-environment, background mode remote-notification | platform/34 already states the no-plugin rule; the four keys were reserved for it. A blank value must not crash a build that has no Firebase project yet (staging has none today) | Yes |
| A61 | Phase 4 respects the circle’s notify_on_update switch (server side, already built) and the OS permission; the per-kind switches of Settings → Notifications arrive with phase 6 and until then every kind is delivered | The switches are a Settings screen; building their storage twice is waste | Yes |
| A62 | Contact card actions in phase 4: call, message, copy, e-mail, open in maps, share (OS share sheet), report by mail. The yearly calendar reminder for a special date is deferred to phase 6 (needs the calendar permission flow and its own copy) | Every other action is an intent or a share sheet; the reminder is a permission and a recurrence rule | Yes |
| A63 | Tablet in phase 4: list and detail panes with selection state (R7.1), sheets stay routes (R7.4), state survives rotation (R16), iOS keeps swipe, Android two-pane shows trailing quick actions instead of swipe. Drag-and-drop reassignment, multi-select bulk actions with undo and the keyboard shortcuts of app/18 move to phase 6 (polish) | They are input-method refinements on top of the same actions; the actions themselves ship here | Yes |
| A64 | “Preview as this circle” calls a new preview_circle(p_circle uuid) returns jsonb (security definer, owner only) that builds the snapshot a member of that circle would see with a synthetic viewer; the app renders it with the contact-card body | build_snapshot is server-only by design (visibility never computed on the client); a preview is the same computation with the circle fixed | Yes |
| A65 | Background refresh: Android WorkManager periodic work every 15 minutes (network constraint), iOS BGAppRefreshTask, both running the same delta fetch and nothing else; a data push triggers the same fetch immediately | The roadmap moved it from phase 2 to phase 4 with push; both APIs are the platform’s only sanctioned background path | Yes |
| A66 | Search tokens are hashed on the device (sha256 hex of the lower-cased, trimmed e-mail; of the trimmed E.164 phone; name tokens lower-cased whole words of three or more letters), pinned by docs/vectors/search-tokens.json generated from the SQL functions and asserted by pgTAP and by both apps | Two round trips per search for a hash is latency for nothing; the vector makes drift a test failure | Yes; the RPCs remain |
| A67 | Invitation reminder: remind_invitation(p_invitation uuid) (security definer, sender only) inserts a second invitation_received notification once, at least seven days after sending, recorded in the new column invitations.reminded_at; the pending row’s updated_at moves so both caches see it | The doc’s “once, after 7 days” needs server truth; a client timestamp can be forged | Yes |
| A68 | The three-second highlight of changed rows after a contact_updated tap is deferred to phase 6; phase 4 opens the card. Favourites reorder with long-press drag where the platform list supports it natively (iOS onMove) and with move up/down actions on Android, both with the accessibility actions phase 3 used for photos | The highlight needs a per-connection previous snapshot kept only for a cosmetic effect; the reorder choice repeats the phase 3 ruling | Yes |
| A69 | A pending invitation’s parties are named through invitation_parties(p_invitations uuid[]) (security definer), which returns each party’s display_name and avatar_path only to the two participants of that invitation; nothing else about the other profile is readable before the connection exists | app/14 shows the requester’s default name and avatar on a received invitation, and the sender’s list needs the recipient’s; row-level security on profiles rightly hides both, so a function scoped to the invitation is the only path that leaks nothing else | Yes |
| A70 | An emails row whose value equals the sign-in address of the account that owns the profile (auth.users.email, compared trimmed and lower-cased) is set is_verified by the server, on insert and on any write of the value, through a before trigger and a security definer lookup the trigger alone may call; onboarding seeds that row with is_searchable on | search_profiles only indexes an e-mail that is searchable and verified, and a client may write neither flag as proof, so nobody could be found by the address they signed in with — the iOS smoke test could not find the second account by e-mail. Supabase Auth already proved that address at sign-up, so the server can say so itself instead of asking for a code that would only confirm what it knows | Yes |
| A71 | Cross-reference uses a new RPC cross_reference(p_tokens text[]) returns table(token text, profile_id uuid, display_name text, avatar_path text) capped at 200 e-mail/phone tokens per call (no name tokens), rate-limited xref:<uid> 30 calls per hour, with the same exclusions as search_profiles (own profiles, opt-outs, blocks, deleted accounts). app/15 and app/14 are corrected to name it | search_profiles is capped at 20 tokens and 60 calls per hour for interactive search; an address book of a thousand entries needs the hash the client already holds echoed back so it can say which contact matched, which the interactive function deliberately hides | Yes, one function |
| A72 | Two mirrored tables: sync_services (id, profile_id, provider in device/google/microsoft, label, account_hint, device_id of the device that holds the credential, circle_ids uuid[], allow_delete default false, last_sync_at, timestamps) and sync_links (id, service_id, profile_id, connection_id null, external_id, synced_hash, hidden default false, last_seen_at, timestamps; unique (service_id, external_id) and a second unique index sync_links_service_connection_idx on (service_id, connection_id) where connection_id is not null). Owner RLS through owns_profile; client insert/update/delete on own rows with column grants; keyset-synced like the other mirrored tables; a service’s rows are deleted with it (on delete cascade); a connection removal nulls connection_id so the entry lands in the delete bucket. Provider cursors and OAuth tokens never leave the device | This is the state a second device needs to show “connected on another device” and to keep reconciliation decisions; tokens and cursors are per device by A21 | Yes |
| A73 | Reconciliation decisions (hide, link, unhide) are the one offline queue: a local sync_decisions queue, replayed in order when the network returns, then reflected in sync_links. Everything else stays write-through with rollback | app/20 names exactly this exception; the decisions are made in bulk on a list the person may work through offline | Yes |
| A74 | Google Contacts authorises with OAuth 2.0 PKCE as an installed-app public client per platform (Android Custom Tabs, iOS ASWebAuthenticationSession), redirect nl.sefaja.incico:/oauth2redirect, scope https://www.googleapis.com/auth/contacts, client ids from build-time values INCICO_GOOGLE_CONTACTS_CLIENT_ID (blank hides the Google provider). Refresh tokens live in the secure store under google_refresh_<service_id>; iOS gets a generic Keychain-backed SecureStore matching Android’s interface. Disconnect revokes the token at Google and forgets it | Public PKCE clients need no secret in the binary (platform/33); the existing Google sign-in is an ID-token flow and cannot grant People API access | Yes; a server-side token check stays out (A21) |
| A75 | Background external sync is a second job per platform: Android a periodic WorkManager request every 15 minutes constrained to unmetered network and battery-not-low; iOS a BGProcessingTaskRequest (nl.sefaja.incico.services) with network connectivity required. Both run the same reconciliation with automatic actions only and skip when the Incico cache is older than 24 hours (the freshness guard) or when a foreground sync is running | The delta-fetch job’s constraints are looser by design; app/15 asks for unmetered only and BGAppRefreshTask cannot express it | Yes |
| A76 | Device contacts are written into a group named “Incico” (Android ContactsContract group in the default account, iOS CNGroup), each entry carrying the profile id in a note/userDefined field, external_id = the provider’s contact id; Incico only ever touches fields it wrote (recorded per link in synced_hash). Google entries carry userDefined incico=<profile_id> and the “Incico” contact group | The docs say so; the marker is what makes “Incico-managed” detectable on a fresh device | Yes |
| A77 | Microsoft/Outlook stays behind the flag: the provider enum, the add-service sheet’s hidden option and the Graph field mapping notes exist, no Graph client or MSAL is added; the flag INCICO_MICROSOFT_ENABLED defaults to off | Q5 is decided as phone contacts plus Google (2026-10-30); a third provider is later work | Yes |
| A78 | Onboarding steps 2.3.1–2.3.3 become active: “About connected services” (Connect a service / Not now), the add-service sheet reused, “Invite people you know” running the cross-reference with per-match invite and a bulk “Invite all in | app/11 reserved the steps for phase 5 | Yes |
| A79 | Account deletion (phase 6 UI) calls the same per-service disconnect (keep or remove Incico-written entries per allow_delete) before the edge function runs; phase 5 delivers ServiceDisconnector on both platforms and the delete-account doc names it | The server cannot reach the phone’s address book after the account is gone | Yes |
| A80 | Telemetry: service_connected {provider} and sync_completed {provider, added, updated, deleted, duration_ms} are sent after consent; a cross-reference run sends no event of its own (invitations already do) | platform/36 reserved the two events | Yes |
| A81 | “Device language” is stored as accounts.preferred_language = null: the column becomes nullable with no default for new rows and the check allows en, nl or null. The app resolves null to the device language (en when the device is neither). The Translator becomes switchable at runtime: a LanguageController exposes a StateFlow<Language> that LocalTranslator reads, so a change recomposes every screen without a restart. The chosen language is also written to devices.locale for this device and into the Auth user’s metadata (data.language), which is what push texts and e-mail templates read | app/17 asks for “Device language” as the default and a value on the account; the current 'en' default cannot express it. Push-fanout already reads devices.locale | Yes |
| A82 | Sort order and phone format are account settings like the theme and the detail preference: accounts.contact_sort (nickname default, name) and accounts.phone_format (national default, international), both in the update grant and mirrored by the delta fetch | They describe how a person reads their contacts, which should not change between their phone and tablet | Yes, move to device prefs |
| A83 | Per-kind notification switches are four account columns, notify_invitations (covers invitation_received and invitation_accepted), notify_contact_updates, notify_contact_removed (all default true) and notify_circle_changes (default false). push-fanout reads them and skips the push for a kind that is off; the row is still written, so the in-app list shows every kind (app/16). system is always pushed. The circle’s own “Send me updates” switch keeps working as before (The contact_removed notification is dropped by A102, 2026-10-29.) | Server-side filtering is the only way the switch also covers a second device and a phone that is off | Yes |
| A84 | Signed-in devices are read through list_sessions() (security definer, own sessions only) returning id, created_at, last_active_at (refreshed_at or updated_at), user_agent and is_current (compared with the session_id claim of the caller’s JWT). revoke_session(p_session uuid) deletes one of the caller’s own sessions other than the current one. “Sign out everywhere else” is supabase-kt signOut(SignOutScope.OTHERS). The device model shown is parsed from the user agent; the app sends Incico/<version> (Android <release>; <manufacturer> <model>) as its user agent to Auth. No IP address is shown or returned | auth.sessions is not reachable through PostgREST; a narrow definer function with an own-rows filter is the pattern used everywhere else. The IP is personal data the screen does not need | Yes |
| A85 | Blocked people are listed through blocked_profiles() (security definer) returning each blocked profile’s id, display_name and avatar_path for the caller’s own blocks only (the invitation_parties pattern, A69). Unblock is unblock_profile(p_profile uuid), which deletes the block, re-derives nothing (search already excludes blocks at query time), writes an audit row and restores findability; it does not restore the connection | profiles RLS hides a blocked profile, so the client cannot name it any other way; a function keeps the audit consistent with block_profile | Yes |
| A86 | Change of sign-in e-mail uses Supabase’s secure e-mail change (double_confirm_changes = true): updateUser(email) and then the two six-digit codes (verifyEmailOtp type email_change) entered in one sheet. After the change the old address stays on the main profile as a verified e-mail detail unless the person removes it (app/17), and the new one is added verified (A70 does that on the server) | Codes rather than links keep the flow in the app, as sign-in does | Yes |
| A87 | Linked sign-in methods list the Auth identities and offer Link (Google) and Unlink (any, never the last). The section is shown only when the build names at least one social provider (INCICO_SOCIAL_PROVIDERS); until Google is configured the section shows only “E-mail and password” with a Set password / Change password action. Apple on Android is not offered (it needs a web flow the product owner has not asked for) | The providers are not configured yet; building the list over what exists is testable now and grows with configuration | Yes |
| A88 | Diagnostics log: a 2,000-line ring buffer in noBackupFilesDir/diagnostics.log, fed by a DiagnosticsLog sink behind the existing Log calls through one AppLog facade; lines carry time, level, tag and a message that never includes a detail value, token or e-mail (a unit test runs the facade over a list of forbidden patterns, and tools/log-check.mjs greps call sites for string templates of known sensitive names). View in the app, share through the Android Sharesheet with a FileProvider. The diagnostics id is a random UUID per install, shown in Help and in the support mail | platform/36 and platform/33 describe exactly this; a facade is the one place the rule can be enforced | Yes |
| A89 | Crash reporting uses the existing minimal PostHog client: after consent, an UncaughtExceptionHandler that chains to the previous handler writes the exception class, the stack frames (class and method names only, no messages), the app version, OS version and device model to noBackupFilesDir/crash.json synchronously, and the next start sends it as a $exception event and deletes it. No message text is ever sent | A crash handler cannot do network work reliably; the next-start pattern is what Saymail’s client does | Yes |
| A90 | Open-source licences are generated at build time by the AboutLibraries Gradle plugin (com.mikepenz.aboutlibraries.plugin, core JSON only, no UI library) into res/raw/aboutlibraries.json; the app renders its own list and each licence text. The font OFL texts from mobile/design/licenses/ are added as manual entries | A generated list cannot go stale; the plugin needs no Play Services, unlike oss-licenses-plugin | Yes |
| A91 | The account deletion and export web page is /{lang}/account/delete/ on the website (the URL delivery/42 already gives the stores): sign in with the six-digit e-mail code (Supabase Auth REST otp and verify, called with fetch from a small first-party script, no SDK), then Export my data (calls export-account) and Delete my account (type DELETE, then request_account_deletion through PostgREST and delete-account). delete-account and export-account answer CORS preflight for https://www.incico.eu and https://incico.eu, plus the origins in the WEB_ORIGINS function secret (where the Netlify deploy-preview pattern https://deploy-preview-*--<site>.netlify.app goes once the site name is known); nothing else. The session lives in sessionStorage and is discarded on leaving. The page is in the sitemap and linked from the privacy policy and the footer | Both stores require a web deletion path (A11); a hand-written fetch keeps the no-third-party-script rule | Yes |
| A92 | Transactional e-mails follow the language: the Auth templates in supabase/templates/ branch on {{ .Data.language }} (set by A81, default English), and the export and deletion mails of the edge functions read accounts.preferred_language and fall back to English | platform/35 requires it; the Auth templates support Go template conditionals | Yes |
| A93 | The yearly reminder on a special date uses Intent.ACTION_INSERT on CalendarContract.Events with RRULE:FREQ=YEARLY, an all-day event, the title “Birthday: app/14 is corrected to drop “with permission” | The insert intent hands the event to the calendar app, which asks the person; the app never reads the calendar | Yes |
| A94 | Changed rows after a contact_updated tap: the delta fetch keeps the previous snapshot of each connection in connections_previous (local only, one row per connection, replaced on each change); the card compares by detail id and highlights rows that are new or changed for three seconds (reduce motion: a static marker for the same time). Opening the card any other way shows no highlight | A68 deferred it; a local copy is enough because only this device needs to know what it last showed | Yes |
| A95 | Tablet pointer and keyboard (app/18): on the expanded layout a contact row can be dragged onto a circle in the Circles pane (with the same confirm sheet as Move); multi-select by long press or Ctrl/Shift-click with bulk Move and Remove and one Undo per batch; keyboard shortcuts J/K (next/previous row), Enter (open), / (search), F (favourite), ? (shortcut overlay), with a visible focus ring; the medium-width rail gets an expand affordance that overlays the labelled rail | Deferred from phase 4 (A63) | Yes |
| A96 | The circle-pane parity item: a member opened from the Circles detail pane keeps the circle selected (the iOS behaviour) and shows the contact card over the second pane with a back affordance to the circle; app/18 is corrected to say so | It keeps the person’s place, which is the tablet principle; iOS already behaves this way | Yes |
| A97 | Tokens: IncicoSizes.circleDot becomes 10 dp as app/19 specifies, and IncicoSizes.avatar 40 dp is added; mobile/design/tokens.json is the source and tokens-check.mjs enforces it | The visual design is the authority; the 12 dp dot was a phase 2 approximation | Yes |
| A98 | android:allowBackup="false" as platform/33 requires; the per-file backup exclusion rules and backup_rules.xml are removed with it. data_extraction_rules.xml stays, reduced to one rule excluding every domain, because from Android 12 allowBackup no longer stops a device-to-device transfer (refined 2026-09-18 in Android D) | The doc is explicit, and the cache and secure store must not travel to a new phone through a backup or a transfer | Yes |
| A99 | Store and compliance artefacts are written in the repo: docs/store/play-data-safety.md (the answers per question, derived from the platform/36 inventory), docs/store/play-listing.en.md and .nl.md (texts from the website copy), docs/store/content-rating.md; screenshots come from the E2E -Screenshots run. Entering them in Play Console is the product owner’s step in phase 7 | The product owner can paste them; the repo keeps them reviewable | Yes |
| A100 | The apps and the website use the v2 palette (blue #3a5fa0/#4d7ac8 primary, gold #8a7033/#c8a84d accent, pure-grey neutrals), the system font at v2’s sizes, radius 16 with v2’s top highlight and shadow, and dark as the default appearance. This supersedes A23’s palette and font choice; the logo, the circle palette and the website’s Inter stay. (The apps’ palette, font, depth and dark default are superseded by A101, 2026-10-29.) | The product owner compared the two apps on 2026-10-17 and asked for the v2 look. | Yes: the tokens are one JSON file and one CSS block. |
| A101 | The apps use the circle-first look (direction C of the 2026-10-29 redesign): Manrope bundled (weights 400 to 800) instead of the system font; warm neutrals with one cyan accent. Light: bg #fafaf8, surface/card #ffffff, fill #f0efec, line #e5e3de, ink #1c1b19, ink2 #3f3d39, muted #6b6862, primary #007a99, primaryText #006a85, onPrimary #ffffff, tint #e3f3f7, accent #9a7a22, success #2f7a4b, warning #8a5a00, danger #b23b3b. Dark: bg #161616, surface/card #212121, fill #2f2f2f, line #3b3b3b, ink #ececea, ink2 #c3c1bc, muted #9a978f, primary #18a6c8, primaryText #7fd8ee, onPrimary #06161b, tint #16343d, accent #d8b14d, success #6cc48e, warning #e9bb62, danger #ea7a70. Flat cards (1 dp line, radius 12), no lit edge, gradient or shadow; header actions carry words; the theme follows the system by default. Supersedes A100’s palette, font, depth and dark default; the logo and the circle palette are unchanged. | The product owner chose it over the v2 look (A) and a Saymail-structured look (B) in five rounds of mocks (mobile/design/redesign/), drawing on Saybook’s and Saymail’s rules. | Yes: one JSON file and the theme files. |
| A102 | No notification when a connection ends: removing a contact, blocking, deleting a managed profile or an account notifies nobody. The contact_removed kind, its push and its Settings switch (notify_contact_removed) are dropped; the card simply leaves the other person’s list at their next refresh. | The product owner, round 4: a removal notice tells someone they were dropped, which is a privacy harm with no benefit. | Yes, the kind can be added back. |
| A103 | Received requests get Ignore beside Answer. Ignoring hides the request from Needs you, from Notifications’ Needs you and from the bell count; it stays answerable under Requests; it sets no expiry of its own (the invitation’s 30-day expiry still applies) and the sender is not told. Stored as invitations.ignored_at, written only by the recipient through ignore_invitation(id). | The product owner, rounds 4 and 5 (“hide, answer later”). | Yes |
| A104 | Onboarding’s detail steps (2.2.1 to 2.2.7) become one hub, Build your card: a checklist of the six categories with a live preview of what a chosen circle sees; each line opens the Profile tab’s detail sheet. | The product owner, round 5. Every category was already optional; one hub shows the result while it is being built and reuses one editor. | Yes |
| A105 | Profile editing is one detail per bottom sheet, opened from its row or a section’s Add; it replaces the category edit pages, their Edit mode and the unsaved-changes guard. Who sees it is the circle sheet with a checkbox per circle and “Done · 12 people see it”. | The product owner, rounds 2 and 5. | Yes |
| A106 | Find people has two segments, Search and In your phone · N; the second runs the cross-reference (cross_reference) over the phone’s address book, needing only the contacts permission. | The product owner, round 3: people look for friends on the Find screen, not inside a connected service. | Yes |
| A107 | A circle is shown by its name in its colour (dot plus name) on every row and detail; the trailing dot of contact rows and the row of visibility dots on details are gone. A circle colour is never a background for text; a selected circle chip is a 22 % tint of its colour with a coloured border and ink text. | The product owner, round 1 (“named label”). Several palette colours fail contrast under white text. | Yes |
| A108 | The Circles tab is the who-sees-what grid: details down, circles across, the detail column locked while the circles scroll sideways; a tap on a cell toggles that circle on that detail. The circle list with Preview sits under it; Circle details keeps the switch list. | The product owner, rounds 1 and 2 (more than four circles must scroll). | Yes |
| A109 | Contacts: circle filter chips replace the collapsible groups; sections Needs you, Changed this week (worked out on the device from connections_previous), Favourites, Everyone and a collapsed Requests line (sent and ignored). | The product owner, round 1. It puts Incico’s promise, current details, on the first screen. | Yes |
| A110 | Services: a summary card across all address books (“3 changes behind”, Review and sync) and one review list per service, with old → new values pre-ticked and “Apply 3 changes”. | The product owner, rounds 1 and 2. | Yes |
| A111 | Settings is a grouped list (App, Privacy and data, Help) under an account card; each row’s second line is its current value. The gear is on the Profile tab only (and the tablet rail). | The product owner, round 3. | Yes |
| A112 | On a tablet the Circles tab stays list + details as written in app/18; the full-width grid was offered and declined. | The product owner, round 4. | Yes |
| A113 | Notifications are ordered Needs you (invitations with Answer and Ignore) then Updates, each update saying what changed and whether it reached the address book; the profile switcher is a list sheet; Profile gets View as chips that show the card as a circle receives it via preview_circle. | The product owner, rounds 1 and 4. | Yes |
| A114 | A sent invitation’s circle can be changed while it is pending, through set_invitation_circle(id, circle) (sender only). | The round 4 sent-invitation mock shows Change beside the circle; without a function it could only be faked by cancelling and resending, which would notify the recipient twice. | Yes |