Overview › Dive Log

Dive Log

Area: 03 Dive Log · 5 items · Source: PADI Mobile Scope Alignment v-ceb35f4d Subject: the unified PADI app — Expo React Native. Every verdict below is about what that app can build, not about what today's apps do.

What this card covers, and what it does not

The area holds 10 items, and unlike every other area in this study it has no carried-forward lane at all. The artefact says so itself: "No equivalent to carry forward — the logbook exists only in the app. The whole area is delta."

This card is the five items in the Q1 2027 Delta laneLOG-01, LOG-02, LOG-03, LOG-04, LOG-09.

The other five — LOG-05, LOG-06, LOG-07, LOG-08, LOG-10 — sit in the Post Q1 2027 lane and are not analysed here. Note the IDs are not contiguous by lane: LOG-09 is a Q1 item while LOG-05LOG-08 are not.

Also out of scope, and each belongs to another area: PRO-01 (Pro QR verification — the instructor's side of sign-off, area 04), ENG-08 (dive-log reminders, area 10, post Q1), and the three items of area 12 Dive Tools & Reference.

Not one of the ten items carries a requirement. No detail drawer, no Jira id, no epic, no Impact, no Effort — for any of them. The artefact's drawer data covers only web-derived items, and this area has none. Everything below is written against a title and a flag.


The finding to lead with

The artefact says this area is not committed. Everything else says it is.

Every one of the ten items is flagged PRK (parking lot) or PQ1 (after Q1). Not a single dive-log item is marked Now. On the artefact's own reading, nothing here is confirmed for Q1 2027.

Set against that:

  • Jira has an epic and five open Q1 stories. PAM-8 Dive Log, fix version Q1 - Release 2027 — with PAM-28 (Basic Manual Dive Log Entry), PAM-29 (Offline Dive Logging + Reliable Sync), PAM-51 (Offline-First Architecture), PAM-68 (Dive log with photos / media) and PAM-77 (Dive-log interactive notifications) all open against the same release.
  • Two feasibility spikes are already closed. PAM-16 Spike: Offline logging + conflict-safe sync and PAM-20 Spike: Offline-first data layer are both Done.
  • The unified app has already built the storage layer. See How to read the evidence, below.
  • Another area is formally blocked on this one. Area 04 Pro's blurb reads, verbatim: "Requirements still in progress; parked until the dive log capability document is finalised."

So a parked, unspecified area has a funded epic, a closed spike, production code, and a dependent area waiting on it. That gap is the first thing to resolve at the readout — not because the engineering is unclear, but because the scope record and the delivery record disagree about whether this work is happening.

This document is the capability document area 04 is waiting for. PRO-01 cannot be scoped until it exists.


How to read the evidence in this card

The unified app is a new client. It inherits no code from padiww/ios-padi-app or padiww/android-padi-app — those apps are being replaced. They appear here as proof that an endpoint answers a real client holding a diver's token, never as a head start.

This card needs a status word the study has not used before.

Built — unified app — the capability exists as code in PadiWW/padi.mobile.app, PADI's own unified-app repository. This outranks POC-proven: a POC settles whether something can work, production code settles that it does work in the app that ships.

It applies to exactly one thing here, and the boundary matters:

  • The durable encrypted store is built. src/features/dives/ carries a DiveLogRepository port with a Realm adapter and an in-memory adapter, opened through src/providers/RealmGate.tsx — which "Holds the tree until the encrypted database is open" — with tests on both adapters. Delivered under PAM-113 (Realm, encrypted at rest, behind a swappable seam) and PAM-114 (migration harness).
  • The feature is not built, and the repo says so plainly. src/features/dives/screens/DiveLogDemoScreen.tsx:1 is headed THROWAWAY — delete when the real dive-log UI lands (PAM-51), and src/features/dives/types.ts:3-6 states: "Deliberately small. PAM-51 owns the real model — sync state, the outbox, buddy and equipment, smart-device imports."

Read every verdict below with that line drawn: the hard part of offline — a durable, encrypted, restart-safe local store — is production code. The dive log itself is not started.

Four mobile surfaces were checked, not two. Beyond the two PADI apps, diviac/travel-rn (PADI Adventures) and padiww/padi-aware-flutter were both searched. Neither has a logbook — Adventures' only match is the string "PADI eCard & logbook" in app/constants.js:200. But neither is irrelevant: both bear on LOG-09, because each has built diver-facing media capture that the logbook has not. See that item.

The three-step test, with a structurally empty middle

Every other card in this study runs: does a mobile app call an endpoint for this → if not, does a PADI web front end → only then is it a gap. Step 2 does not exist for this area. There is no logbook on any PADI web property; the artefact confirms it and the WEB dataset contains zero items for area 03.

That has one useful consequence and one trap. The useful consequence: where a verdict here is Existing, it rests on mobile-proven evidence, the strongest non-POC class. The trap: the usual rescue — "a web front end already does it" — is unavailable, so a genuine gap here is a real gap with nowhere else to look.


The one endpoint, and what it does not have

Both current apps talk to a single Hasura-shaped GraphQL endpoint. One POST; the operation is chosen by the query string in the body.

Production Staging
Logbook logbook.global-prod.padi.com/api/Logbook logbook-stage.global-np.padi.com/api/Logbook

Auth is the header pair the app already holds — Authorization: Bearer <token> plus affiliate-id, with x-platform — cited at padiww/ios-padi-appPADI/data/api/api/LogBookService/LogBookService+Request.swift:103-108 and padiww/android-padi-appapp/src/main/java/com/duns/padiapp/data/api/LogbookApi.kt:13,16.

One asymmetry worth carrying: iOS sends the accessToken, Android sends the idToken (AuthenticatorInterceptorRefreshToken.kt:35). The unified app must pick one deliberately and confirm the gateway accepts it.

What the schema does not expose, and this governs LOG-03:

There is no client_id, no version and no deleted column on logbook_logs — only update_date. So the server offers no idempotency key and no optimistic-concurrency token, and delete has no tombstone.

That is not an inference. It is recorded in our own POC's adapter header, axelerant-padi/mobile-pocsrc/dive/logbookServer.ts:14-28, alongside what the POC did about it. It is the single most consequential fact in this card and the substance of PADI-26.

And there is no dive-site relation. dive_location is a plain string on both platforms; the logbook_dive_site table is a name-only autocomplete (id, name) whose selected id is never persisted on the log. The dive log therefore has no coordinates — which is why LOG-06 (dive log on map) has nothing to join on when it arrives.


Where the vendor's documentation and the shipped code disagree

The incumbent vendor left knowledge-transfer documentation for the logbook on both platforms — iOS KT docs/Logbook_Feature_Documentation.html (April 2026) and Android KT docs/Flow Transfer/Create Logbook Flow Summary.html. It is the only PADI-side written description of this feature that exists, and it is genuinely useful for intent.

It is not reliable for contract. Three claims do not survive a read of the code, and all three bear on items in this card:

KT documentation says The code has Bearing
LogbookExperience"Notes, marine life observations, photos, rating" logBookId, feelingType, notes, buddies, diveCenterios-padi-appPADI/model/view/LogbookExperience.swift:34-47 LOG-09. Photos, marine life and rating do not exist
LogbookInstructorInfo with instructorEmail, verificationDate, verificationMethod firstName, lastName, memsysMemberNumberPADI/model/view/LogbookInstructorResponse.swift LOG-04. There is no verification audit trail; it is a roster record
Storage — "LogbookOfflineProvider, UserDefaults" JSON files under Documents/LO.bundle/. UserDefaults holds only zip bookkeeping — LogbookOfflineCachingHelper.swift:115-128 see the note below

That third row needs saying carefully, because our own technical direction paper repeats it. TDP-01 motivates the offline rebuild partly with "Persisting logs in UserDefaults is fragile". Dive logs are not in UserDefaults — they are in JSON files, written atomically, in a directory carrying FileProtectionType.complete. The data-loss complaint is real and is the right reason to rebuild — TDP-01 records that "35% of active divers flagged data reliability; one diver abandoned the app after losing 80 logged dives" — but the mechanism attributed to it is wrong, and repeating an inaccurate premise to PADI would undermine an argument that stands perfectly well without it.

The honest version: the current store is a whole-file rewrite of every dive on every save (LogbookOfflineProvider.swift:144-150), unindexed and O(n), with no transaction across the sync bookkeeping. That is a sound reason for a database. UserDefaults is not.


LOG-01 — Logbook list (count, sort, tabs: All / Training / Recreational / Drafts)

Flags PRK parking, Q1 delta · API status: POC-proven · Verdict for the unified app: buildable — built on the target stack against the real endpoint.

What the unified app has to deliver

A list of the diver's dives with a total count, sortable, split across four tabs — All, Training, Recreational and Drafts.

What it can rely on

All of it, and from two directions. Both current apps implement this exact screen against the paged list query — android-padi-appapp/src/main/res/raw/query_get_full_logbook_paging_all_id_desc:1-66 (GetPagingAllDive), and the iOS equivalents at ios-padi-appPADI/data/api/api/LogBookQueries/LogBookQueries+Get.swift:49-109. The list query is the one operation our POC confirmed against staging with a captured request (mobile-pocsrc/dive/logbookServer.ts:10-12).

The four tabs are a client-side filter over log_type and status, not four queries — mobile-pocsrc/dive/filters.ts implements exactly that as pure functions, with tests.

What it has to build

The screen. Counts and totals should be computed locally, which is what both apps already do: iOS counts the cached array (LogBookService+Get.swift:197-199) and Android runs SQL aggregates as reactive flows (LogbookDao.kt:36-43). Server-side aggregate queries exist on both platforms and are dead code in both — a useful signal that local aggregation over a complete local cache is the right shape, not a shortcut.

And the POC ships the screen, not just the query — axelerant-padi/mobile-pocsrc/dive/screens/DiveListScreen.tsx over the confirmed list query, with the tabs as the tested pure filters above. That is why this item reads POC-proven rather than Existing — mobile: the evidence is a working Expo screen against PADI's staging logbook, not an inference from two apps being replaced.

What blocks it

Nothing. This is the least ambiguous item in the card.


LOG-02 — Dive log entry

Flags PRK parking, Q1 delta · API status: Existing — mobile · POC-proven for the form · Verdict for the unified app: buildable; the model is larger than the title suggests, and one piece of it is not served by any API.

What the unified app has to deliver

Create and edit a dive — both a recreational dive and a training dive, which are different forms over one record.

What it can rely on

The whole write surface exists and is exercised daily by two shipping apps. The create mutation is a single nested insert across six tableslogbook_logs with depth_times, conditions, equipment, experiences and skills — at ios-padi-appPADI/data/api/api/LogBookQueries/LogBookQueries+Insert.swift:153-174, mirrored on Android at app/src/main/res/raw/query_insert_logbook:1. Update is two mutations, one per dive type (UpdateRecreationalDiveLog, UpdateTrainingDiveLog); delete cascades six tables by hand (LogBookQueries+Delete.swift:12-52).

The POC has since rebuilt the whole entry flow in Expo, and it goes further than the 14-field edit screen the first pass recorded:

  • src/dive/screens/DiveEditScreen.tsx — the recreational form.
  • src/dive/screens/PickTrainingScreen.tsx + SkillsScreen.tsx — the training flow, including the skills page.
  • src/dive/useTrainingCourses.ts — the course list comes from the diver's real entitlements, GET /v2/entitlements/{affiliateId}, the same call the Learning tab makes, with a bundled fallback for guests.
  • src/dive/diveSites.ts — dive-site autocomplete against the real logbook_dive_site _ilike query, which confirms from a third client that the selected id is never persisted on the log.

What it has to build

The forms, and one modelling decision that is not the app's to take alone. Training and recreational dives are keyed differently in the current apps — training reconciles on log_number, recreational on log_id (android-padi-appSyncLogbookDataWorker.kt:56-57) — and the POC records that this "is unconfirmed at the schema level, so the POC models it as a single tag on one shared log shape" (mobile-pocsrc/dive/types.ts:20-24). Two reconciliation keys on one table is a sync bug waiting to be written; the unified app needs one key strategy — SCOPE-33.

Two smaller drags, both recorded in the POC rather than guessed:

  • log_course is a server enum, and free-text course titles are rejected with a data exception. The POC keeps the field local-only and does not push it (src/dive/logbookServer.ts:296-300) — but see the next section: the values are recoverable from the shipped apps, so PADI-27 is now a confirmation rather than a discovery.
  • Skills are not read back. They live in a child table the read query does not select, so they are local-only on pull (logbookServer.ts:107-110).

The training curriculum is not served by any API — and never has been

This deserves its own heading because it is easy to misread as a gap, and it is not one.

The server stores log_course and log_number. It does not store what those mean. The names of the training dives, which dives belong to which course, and the skills required on each are hardcoded in every client:

Client Where What it holds
Android padiww/android-padi-appapp/src/main/java/com/duns/padiapp/domain/BaseAppSettingsIpm.kt six courses, each with its LogbookTrainingDive list, each dive with its required TrainingDiveSkill and checkable FlexibleSkillType sets; names in app/src/main/res/values/strings.xml:1216-1241
iOS padiww/ios-padi-appPADI/feature/logbook/trainingdivecourses/model/TrainingDiveCourse.swift:11-70 a 13-case enum: the same dive names, the same logNumber 1–13 mapping
The POC axelerant-padi/mobile-pocsrc/dive/trainingCatalogue.ts the same pattern, and it says so at :76"The real per-dive lists are BUNDLED/hardcoded there too (not an API)"

So this is client-owned reference data by established design, not a missing endpoint. Three clients, three copies, no API. Worth asking whether that should continue — a curriculum change currently requires an app release on every platform — but it does not block anything. SCOPE-49.

The build requirement it creates is specific, and an estimate will miss it. Training dives reconcile on log_number, and the native apps bind log numbers 1–13 to particular dive names — 1 is Open Water Training Dive 1, 10 and 11 are the two Open Water Rescue Scenario dives, 13 is ReActivate. The POC's bundled catalogue is a different curriculum: it carries Confined Water Dive 1–5 and Open Water Dive 1–4, adds Divemaster and Nitrox courses the native apps do not have, and does not reproduce the 1–13 mapping.

The unified app must adopt the shipped log_number → dive-name mapping, not the POC's. A diver's existing training dives are already on the server under those numbers; a new client with a different table renders them under the wrong names. This is a cheap thing to get right and an expensive thing to discover after migration.

The log_course enum values, recovered from two shipped clients

PADI-27 asked what the enum values are. Both apps declare them:

iOS — LogBookTrainingCourseType.swift:11-16 Android — BaseAppSettingsIpm.kt:1041-1053
Open Open
Advanced Advanced
Rescue Rescue
DSD DSD
Reactivate Reactivate
Specialty/Distinctive SpecialtyAndDistinctive

Five agree. The sixth is an iOS defect, and a GraphQL enum value settles which side is wrong: it cannot contain /, so Specialty/Distinctive is not a legal log_course. iOS sends exactly that in its log-number query — TraingDiveCourseViewModel.swift:83 passes course.rawValue into getLogNumbers — while iOS's own read and write paths use the correct SpecialtyAndDistinctive (PADI/model/view/LogBook.swift:260, PADI/feature/logbook/recreationaldivepage/view/RecreationalDivePageViewController.swift:60).

So PADI-27 narrows to a confirmation: here are six values two clients send — is that the complete set? The unified app can stop treating log_course as unwritable, and the POC's local-only handling can be lifted.

What blocks it

Nothing in availability. SCOPE-33 (one reconciliation key), the narrowed PADI-27 (confirm the six enum values) and SCOPE-49 (who owns the curriculum) shape the build; none prevents it.


LOG-03 — Offline dive logging + reliable sync

Flags PRK parking, Q1 delta · API status: POC-proven Verdict for the unified app: buildable — the sync engine is built, tested and running against PADI's real logbook. Three mutation documents are the one thing still to pin.

This is the most thoroughly evidenced item in the entire study, and the one where over-claiming would do the most damage. The hard part is proven: the engine, the state machine, the conflict detection and the recovery paths all exist on the target stack with tests, and the read path is confirmed against staging. The cheap part is not: the three write mutation documents are placeholders. Do not let a readout collapse either direction — this is not "offline sync is unproven", and it is not "offline sync is done".

What the unified app has to deliver

A diver on a boat with no signal logs a dive, closes the app, flies home, opens it and the dive is there and reaches the server — without duplicating, without silently losing an edit made on another device.

What it can rely on

A complete sync engine, on the target stack, with tests. axelerant-padi/mobile-pocsrc/dive/ is roughly 5,500 lines across 28 source and 12 test files. The parts that matter:

  • A durable outbox in Realm. The queue is not a side table — it is the set of records whose syncStatus is not synced (src/dive/diveSync.ts:102-108). Writes are wrapped in Realm transactions "so the outbox state is durable and ACID across app restarts" (src/dive/RealmDiveLogRepository.ts:1-2).
  • A five-state machinedraft / unsynced / pending / synced / conflict (src/dive/types.ts:12-17) — which is verbatim what TDP-01 specified for dive logs. The direction paper and the implementation agree, which is rarer than it should be.
  • Crash recovery by construction. pending is included in the drain filter, so a record left mid-flight by a kill is picked up on the next run rather than stranded.
  • Conflicts detected on both paths — on push via a server conflict, and on pull when the server version advanced past our base (diveSync.ts:127-148, :178-190) — and always surfaced, never silently resolved. The docblock is explicit: "the UI defaults to manual resolution so a dive is never silently lost" (diveSync.ts:215-216).
  • Partial-failure recovery. A network failure rolls that one record back to unsynced, stops the drain, and leaves everything already pushed as synced.
  • 16 tests structured as four acceptance criteria (src/dive/__tests__/diveSync.test.ts): offline create/edit, two-device conflict, mid-batch failure with clean resume, and ten days of accumulated edits draining in one sync. Plus 7 more at the HTTP boundary.

And the store underneath it is Built — unified app — the encrypted Realm repository described above.

What it has to build — and what has to be confirmed first

The write contract. The POC's read path is confirmed against staging with a captured request. Its create, update and delete mutations are explicitly labelled placeholderssrc/dive/logbookServer.ts:14-28 and again inline at :150: // ⚠️ UNCONFIRMED write mutations (placeholders; see header).

Because the schema has no client_id, version or deleted column, the POC implements both guarantees client-side:

  • idempotency by re-reading to find a row a lost acknowledgement already created;
  • optimistic concurrency by reading the current row and refusing to overwrite one that moved past its base.

Its own assessment: "this is a read-then-write check with a small TOCTOU window, acceptable for the POC." Server-enforced concurrency exists only against the POC's mock server. On the real backend, two devices writing at once can still interleave inside that window.

So the engine is proven; the contract is a proxy. Closing it is one question to PADI — PADI-26 — and it is cheap to answer.

Why this still reads as buildable rather than blocked. Nothing here is a capability PADI has to build. The mutations exist — both shipping apps call them daily; what the POC lacks is the confirmed document, which is a capture away. The missing version column is a real contract gap and it sets the quality of the guarantee, not whether the feature ships: the POC's client-side check already works, with a TOCTOU window that PADI-26 would close. That is a known cost, not an unknown risk, which is the distinction this card exists to draw.

What is honestly not proven

Stated plainly, because the POC states it plainly and a reader who takes "proven" at face value will be misled:

Claim Status
Offline queue, conflict detection, partial-failure recovery Proven — deterministic tests, mock server, controllable clock
The list/read query against the real backend Proven — captured request against staging
Create / update / delete against the real backend Not proven — placeholder mutations
Restart durability Simulated — the test rebuilds the repository in memory rather than reopening a real Realm
Two devices Against the mock only — no test runs two real devices
TDP-01's own acceptance bar, "verified on physical devices" Not met
Background sync Not implemented — sync is on-launch and on-reconnect only

None of these makes the item unbuildable. All of them belong in the estimate.

What blocks it

PADI-26 — and it gates the strength of the guarantee, not the build. Everything else is work, not risk.


LOG-04 — Instructor verification — offline by ID lookup

Flags PRK parking, Q1 delta · API status: POC-proven — including the directory sync neither shipped app gets right · Verdict for the unified app: buildable — but check the requirement is still live before building it.

What the unified app has to deliver

A student submits a training dive to an instructor for sign-off, identifying the instructor by their PADI number, and it works without connectivity.

What it can rely on

The mechanism is simple and both apps implement it. Verification is a status transition plus an instructor member number on the loginit → draft → pending → verified | qrCodeVerified | resubmit (ios-padi-appPADI/model/view/LogBookStatus.swift:11-18), with the signer recorded as memsys_member_number. Two entry paths: manual number entry, or scanning the instructor's QR code.

Offline lookup works because the instructor roster is on the device. Android also has a REST fallback — GET /api/Logbook/instructor/{memberNumber} (android-padi-appapp/src/main/java/com/duns/padiapp/data/api/LogbookApi.kt:20-23).

The POC rebuilt this too: QR parsing, instructor lookup, submit-for-verification, and a paged offline instructor directory (mobile-pocsrc/dive/qr.ts, src/dive/instructorLookup.ts, src/dive/verify.ts).

What it has to build — and what not to copy

Do not reproduce how the roster gets on the device. Both apps ship the entire PADI instructor directory inside the app binary and unpack it to unencrypted local storage — an 81 MB JSON on iOS, a 14.9 MB CSV on Android.

The replacement has since been built, and this is the item's biggest change. The POC now syncs the directory over the wire instead of bundling it:

  • src/dive/instructorDirectory.ts — the bulk count (logbook_instructor_info_aggregate) and paged fetch (logbook_instructor_info), against the real logbook endpoint with the real Bearer + affiliate-id, the same transport as the dive-log sync.
  • syncInstructorDirectory in src/dive/instructorLookup.ts:152-245 — a resumable persisted cursor, pages written to Realm as they arrive, the offset advanced by rows the server returned rather than rows kept (so filtering a bad row cannot desync paging), and every write bound to the session that started it.
  • src/dive/clearOnSignOut.ts — the directory is wiped on sign-out or an account swap, "the directory holds member numbers + names (PII) fetched under the previous session, so it must not outlive that session."

That last point is the substantive difference from the shipped design: a synced, session-scoped cache that disappears with the session, rather than a permanent copy of the roster in every installed binary.

One half is still deferred, and the card should not paper over it. The incremental path is bounded and disk-backed. The rebuild path — used when the persisted cursor is no longer a safe resume point — still stages the whole replacement set in JS before one atomic swap, and the code names the limit itself: at "the native apps' ~678k rows … risks OOM / a UI freeze", with the production design being "generation-tagged rows page-by-page, then atomically flip the active generation and prune the old one (never all-in-JS)" (src/dive/instructorLookup.ts:188-193). Estimate that; do not assume it falls out of the POC.

The paging bug in both shipped apps, found by rebuilding it

The POC could not copy the native paging, because the native paging is wrong. instructorDirectory.ts:11-20 states it:

"kms-android orders by affiliate_instructor_id alone and kms-ios sends no order_by at all, but offset paging is only correct if the ordering is a TOTAL order. Rows tying on the sort column may come back in a different relative order on each request, so a tied group straddling a page boundary silently skips rows (and duplicates others) — and the skipped instructors then fail offline validation while the cursor reports 'complete'."

Read the consequence, not the mechanism. A student cannot submit a training dive to a real instructor whose row happened to fall in a tied group at a page boundary — and the app believes its directory is complete, so nothing retries and nothing reports. The POC fixes it by adding memsys_member_number as a tie-breaker, chosen because it is already assumed unique (it is the local primary key and the sole _eq filter of the single-instructor lookup), whereas affiliate_instructor_id's uniqueness is undocumented.

This belongs in the defect list below as well as here.

What blocks it — and this is the item's real question

iOS has already removed in-app verification. LogbookSubmitVerifyViewModel.swift:29-42 no longer submits anything; its only action deep-links to the separate PADI Training app, with the file header still carrying its previous name. Android still implements verification in-app.

Two shipping apps now disagree about whether dive-log verification belongs in the consumer app at all, and the iOS change looks deliberate rather than broken. Before this is estimated, somebody should say whether the unified app owns verification or hands it to PADI TrainingSCOPE-32.

It matters beyond this item: PRO-01 is the instructor's side of the same flow, and area 04 is parked waiting on this document.

One thing to fix regardless. The KT documentation describes a verification record with an email, a date and a method. No such record exists — the log carries a member number and nothing else. There is no audit trail of who verified a dive, when, or how. For a training record that feeds certification, that is worth raising on its own.


LOG-09 — Dive log with photos / images / media

Flags PRK parking, Q1 delta · API status: Gap · Verdict for the unified app: cannot be delivered — there is nothing to write to.

What the unified app has to deliver

A diver attaches photos to a logged dive.

What it can rely on

Nothing. This is the one true gap in the card, and it is absent at every level:

  • No field. No image, photo, media or attachment field on any dive-log model on either platform. LogbookExperience — the sub-model the KT documentation claims holds photos — carries feelingType, notes, buddies and diveCenter, and nothing else (ios-padi-appPADI/model/view/LogbookExperience.swift:34-47).
  • No table. Nothing in the six-table nested insert carries media.
  • No upload path. The only photo upload anywhere in the estate is the moderated certification photo, which is a different pipeline for a different artefact.

PADI already runs this architecture — the gap is the logbook's, not the estate's

The verdict does not move: there is still no field, no table and no upload path for dive media. But "a PADI platform build" is the wrong size, because the pattern exists in-house and ships.

PADI AWARE has the complete pipeline, and it is the cheap shape — the backend never proxies the bytes:

Step Evidence — padiww/padi-aware-flutter
Ask for upload slots POST /attachment_presigned_urls with {attachment_presigned_url: {attachment_keys: […]}}packages/data/lib/src/http/services/presigned_url_service.dart:4-11, orchestrated at packages/data/lib/src/datasources/remote/s3_remote_storage.dart:16-42
Client uploads straight to S3 A POST-policy form upload carrying policy, x-amz-algorithm, x-amz-credential, x-amz-signature and success_action_statuspackages/data/lib/src/http/s3_api/s3_api.dart:9-45
The record references media by key MediaModel{key, url}packages/data/lib/src/models/media_model.dart:6-9; the per-file loop at packages/data/lib/src/repositories/survey_repository_impl.dart:146-167
The survey record carries them medias alongside latitude/longitudepackages/domain/lib/src/entities/survey_result.dart:38-69

And capture-and-upload is not new ground in a PADI consumer app either. PADI Adventures ships a diver-facing photo upload — react-native-image-picker into a multipart interceptor (diviac/travel-rnapp/lib/transformRequest.js:17-52) — used for the profile photo, a buddy photo, and front and back photos of a physical certification card (app/components/form/CertificationCardsForm.js:19-32,168-172). A different realm and a different pattern (Django multipart rather than presigned S3), but it means the client half of LOG-09 has been built twice inside PADI already.

So scope this as a port, not a design exercise. The question to PADI stops being "how should dive media work?" and becomes "can the logbook adopt the attachment_presigned_urls pattern AWARE already operates, or does it need its own?" That is a much smaller conversation, and it has a named reference implementation attached.

What blocks it

A PADI platform build — but a bounded one: a media field on the log, a table to hold the references, and an upload path that can very likely be the one AWARE already runs. PADI-28, now phrased as adopt the existing pattern or build a new one rather than as an open design question.

There is a second question underneath it that should be asked at the same time, because the answer changes the size: is this diver-private storage, or does dive media feed anything else? It is not hypothetical — DEST-01 is a committed item with a full requirement whose scope says "a 'latest sightings' feed pulled from divers' logbooks" and "pulls the details of the marine life spotted at a destination from the dive logbook of the divers." A committed item depends on logbook content this area has not been asked to produce — there is no marine-life field either. That is worth putting to the client as one question rather than two.


What this card owes PLT-01

PLT-01"Shared offline foundation (save + sync + conflict handling)" — is in area 11, is flagged Now, and is the only confirmed item anywhere in this dependency chain. Certifications deliberately deferred its write half to here, and the warning in that card was specific: "If the foundation is specified from the dive-log side alone, the unified app will be given write machinery this area cannot use."

This card can now supply the write-side scope, because it has been built once already:

  1. A durable outbox — the queue is the record state, not a side table, and it is ACID across restarts.
  2. A five-state lifecycle visible in the UI — draft / unsynced / pending / synced / conflict.
  3. Conflict detection on both push and pull, always surfaced rather than auto-resolved.
  4. Partial-failure recovery — one record rolls back, the drain stops, what was pushed stays pushed.
  5. Idempotent create across a lost acknowledgement.
  6. A generalisation cost that is real. The POC's engine is hardcoded to 14 dive-log fields (src/dive/diveSync.ts:42-45); DiveLog is a concrete type, not an envelope. There is no generic mutation queue in the estate. Generalising it is PLT-01's actual work, and it should be estimated as such rather than assumed to fall out of the dive-log build.
  7. A second, different offline shape in the same feature. The instructor directory (LOG-04) is a large, pull-only, resumable, session-scoped cache with a persisted cursor — no outbox, no conflicts, but paging and eviction concerns the dive-log queue does not have. It sits closer to Certifications' read-only cache than to the dive-log outbox, which makes three distinct offline shapes inside one foundation: pull-only cache, write outbox, and paged bulk sync. PLT-01 should be scoped against all three, not the outbox alone.

Certifications need only points 1 and 2; dive logs need all six. Scope PLT-01 from this list, and note that two more consumers — ID-04 and ID-06 — are waiting on the same queue.


What the current apps get wrong, and the new app should not inherit

Three defects, all confirmed in source, all independent of any scope decision:

  1. Android's sync worker swallows every failure and reports success. android-padi-appSyncLogbookDataWorker.kt:50-53 is catch (ex: Exception) { //do nothing } followed by an unconditional Result.success(). No retry, no backoff, no telemetry — a failed sync is invisible and only recovers when something else happens to re-enqueue the worker.
  2. Android silently discards an instructor's verification. If the local row has an unsynced edit, the server row is not applied (SyncLogbookDataWorker.kt:233-242) — so a dive an instructor has just marked Verified can stay pending on the diver's phone indefinitely. iOS handles this case explicitly (LogbookOfflineProvider.swift:245-251), which makes it a divergence rather than a shared limitation. Two apps on one backend with different merge semantics is the exact failure PLT-01 exists to end.
  3. A duplicate-row window on both platforms — if the process dies between the server insert and the local write-back of the returned id, the next sync inserts again. The POC closes this with an idempotent create; neither shipping app does.
  4. The offline instructor directory can silently skip instructors on both platforms. Offset paging is only correct over a total order; Android orders by affiliate_instructor_id alone and iOS sends no order_by at all, so a tied group straddling a page boundary drops rows — and the sync still reports complete. A student then cannot submit a dive to an instructor who does exist, with no error anywhere. Found by rebuilding it; see LOG-04.
  5. iOS sends a malformed log_course value. LogBookTrainingCourseType.swift:11-16 declares the specialty case as Specialty/Distinctive, which cannot be a GraphQL enum value, and TraingDiveCourseViewModel.swift:83 passes that rawValue straight into the log-number query. iOS's own read and write paths use the correct SpecialtyAndDistinctive, and Android has only ever used that — so this is a one-constant fix in a file the unified app will not inherit, and it is worth a ticket against the current app regardless.

Summary

Item Verdict for the unified app Blocked on
LOG-01 POC-proven — a working Expo screen on the confirmed query nothing
LOG-02 Buildable · POC-proven for the form — the whole write surface exists SCOPE-33 key strategy · PADI-27 (now a confirmation) · SCOPE-49 curriculum ownership
LOG-03 Buildable · POC-proven — engine, state machine and recovery all tested PADI-26 — no version or idempotency column; gates the strength of the guarantee, not the build
LOG-04 POC-proven — including the directory sync neither shipped app gets right SCOPE-32 — iOS has moved verification out
LOG-09 Gap — no field, no table, no upload path PADI-28 — a bounded platform build; AWARE is the reference implementation

Distribution: 4 POC-proven · 1 gap.

Four of five are buildable, and the fifth is the only real gap in the area. That is a stronger position than any card in this study except Identity & Authentication — and unlike that card, the hard part here is already written: an encrypted durable store in the product repo, and a tested sync engine in the POC.

Re-read item by item on 18 September, and the picture got stronger in three places and no weaker anywhere. The first pass wrote four of five verdicts against the two shipped apps; re-reading them against axelerant-padi/mobile-poc as it now stands moved LOG-01 and LOG-04 to POC-proven and let LOG-03's headline say buildable rather than unverified. LOG-09 did not move, because a missing backend is not something a client-side POC can settle.

Two corrections came out of the same pass, and both were in this card's favour:

  • The training-dive catalogue is not a gap. It looked like one until the shipped apps were read: all three clients hardcode the curriculum, and no API has ever served it. See LOG-02.
  • PADI-27 is nearly answered. The log_course enum values are declared in both shipped apps; the question shrinks to confirming the set is complete.

What has to be answered

  1. Is this area committed or not? (SCOPE-34) Ten items flagged park-or-later, against a Q1 epic, two closed spikes, production code and a dependent area. Whatever the answer, the artefact and the backlog should be made to agree before anyone estimates.
  2. Does the logbook schema have a version or idempotency column? (PADI-26) One question, and it decides whether reliable sync is server-guaranteed or a client-side approximation with a TOCTOU window. The cheapest high-value question in the card.
  3. Who owns instructor verification? (SCOPE-32) iOS has already handed it to PADI Training. If that is the direction, LOG-04 shrinks to nothing and PRO-01 changes shape.
  4. Where does dive media live? (PADI-28) Nothing stores it — but AWARE already runs a presigned-S3 attachment pipeline, so the question is adopt it or build a new one, not how should this work. And DEST-01 — a committed, specified item — already depends on logbook content that does not exist.
  5. Are those six log_course values the complete set? (PADI-27) Open, Advanced, Rescue, DSD, Reactivate, SpecialtyAndDistinctive, recovered from two shipped clients. A confirmation, not a discovery — and it lets the unified app write the field the POC currently holds back.
  6. Who owns the training curriculum? (SCOPE-49) Three clients each carry their own copy of the courses, their dives and their skills, because no API serves it. Changing the curriculum today means shipping an app. Worth a decision even if the answer is "leave it".

And one that is ours, not PADI's

The POC is strong enough to be quoted carelessly. It has not written to the real backend. Before this card is used to size anything, someone should pin the create/update/delete mutations against staging and re-run the acceptance matrix on a physical device, which is what TDP-01 asked for and what has not yet happened.