CanPUFF canpuff.org

canpuff.org PuffTab iOS mapping draft source 

Mapping: PuffTab iOS ⟷ CanPUFF v1

DRAFT (2026-07-10). This document defines the lossless, bidirectional mapping between the PuffTab iOS app’s persisted SwiftData model (13 entities) and CanPUFF v1. It is the conformance contract for the first reference implementation: export from iOS, import anywhere, re-import to iOS — zero data loss.

Conventions used below:

1. Puff → journal consumption

iOS field CanPUFF
id id
timestamp at
grams amount.grams
supply?.id supply
method?.id method
notes notes (empty string → omit)
photo attachment (content-addressed; see §14) → photo
purpose context.purpose
effectiveness (1–10, default 5) effects.rating
sideEffects effects.sideEffects
location (string) context.location.name
latitude / longitude context.location.lat / .lon — previously not exported; MUST be exported now
isShared implied: shared.people > 0 (see N2)
sharedWithCount shared.people
isGift shared.gift
sortOrder ext.pufftab.sortOrder — previously not exported
createdAt / updatedAt created / updated

2. Supply → catalog supply

iOS field CanPUFF
id id
name name
shortName shortName (empty → omit)
varietyRaw (“Hybrid”/”Indica”/”Sativa” or empty) variety lowercased (hybrid/indica/sativa). An empty string means Not specified and exports no variety; an absent variety imports as empty, never Hybrid. If a nonempty stored string (lowercased) is not one of the three, omit variety and preserve the raw string as ext.pufftab.varietyRaw; importers restore it verbatim
thc, cbd thc, cbd
originalTHC, originalCBD originalThc, originalCbd
totalTerpenes totalTerpenes
terpenes (SupplyTerpene rows) terpenes[] — see §11
grams grams
gramsstart gramsStart
cost cost
costpergram derived (cost / gramsStart) — not exported (N3)
currencyCode currency — if the stored code is empty, cost and currency are both omitted and the amount is preserved as ext.pufftab.costNoCurrency (Core §2 pairing; never fabricate a currency)
taxRates (SupplyTaxRate rows) taxes[] (array of tax-rate ids) — see §12
purchasedon / packagedon purchased / packaged — MUST be exported in full RFC 3339 timestamp form (the stored instant), never date-only: the app keys supply dedupe on the instant
expiryDate expiry — full RFC 3339 timestamp form, as above
shop?.id / brand?.id shop / brand
rating (0–5) rating
photo attachment → photo
notes notes = Markdown body
lowSupplyThreshold lowThreshold
isFinished / finishedAt finished / finishedAt
isFavorite favorite
lastUsed ext.pufftab.lastUsed (RFC 3339, omit when nil) — independently persisted state, not derivable: the app sets it on finish actions, backdated puffs, and watch logging. Importers set it verbatim, falling back to max(at) of referencing consumption events only when the ext value is absent
sortOrder ext.pufftab.sortOrder — previously not exported
isSharedSupply sharedSupply — previously not exported; MUST be (it changes consumption math)
createdAt / updatedAt created / updated

3. Method → catalog method

iOS field CanPUFF
id, name, notes id, name, notes (body)
grams (default dose) defaultGrams
shortName shortName
aliases (comma-separated string) aliases (array — split on ,, trim, drop empties; join on export back). Whitespace-around-comma and empty items are normalized away; this normalization is canonical and intentional
icon (SF Symbol name) ext.pufftab.icon (Apple-specific vocabulary)
isQuickAccess ext.pufftab.quickAccess
sortOrder ext.pufftab.sortOrder
createdAt/updatedAt created/updated

4. Brand → catalog brand

id, name, rating, photo→attachment, notes→body, producer?.id→producer, sortOrder→ext, timestamps→created/updated.

5. Producer → catalog producer

iOS CanPUFF
legalName legalName (falls back to name in iOS init; export verbatim)
licenseNumber licenseNumber
website, headquarters same
yearFounded (0 = unset) yearFounded (omit when 0)
isActive active
rest as Brand pattern (rating, photo, notes→body, sortOrder→ext, timestamps)

6. Shop → catalog shop

iOS CanPUFF
name, rating, photo, notes→body same pattern
city / address city / address
location (DEPRECATED) if non-empty and city empty: migrate into city; else ext.pufftab.legacyLocation
businessAddress (DEPRECATED) if non-empty and address empty: migrate into address; else ext.pufftab.legacyBusinessAddress
latitude/longitude lat/lon
phone, email, website same
onlineOrderingUrl orderingUrl
offersDelivery delivery
chain?.id chain
customerId customerId (note: iOS effectiveCustomerId fallback-to-chain is derived; CanPUFF specifies the same fallback in Core §5.2)
sortOrder ext.pufftab.sortOrder

Delete-rule warning (implementation, not format): iOS cascades Shop deletion to its Supplies. CanPUFF has no delete cascades — deletion semantics live in the app; the sealed profile records only per-object tombstones. The iOS importer MUST NOT re-apply its cascade when materializing a vault (a vault may legitimately contain supplies whose shop was deleted → dangling ref, which readers must tolerate).

Importer requirement (events): the iOS importer MUST materialize consumption events whose supply or method is absent or unresolvable — Puff.supply/Puff.method are optional stored properties; only the convenience init requires them. Silently skipping such events (current DataTransferView behavior, ≈ lines 2130–2138) is non-conforming.

7. ShopChain → catalog chain

id, name, website, customerId, logo→attachment (logo field), notes→body, sortOrder→ext, timestamps.

8. Terpene (dictionary) → catalog terpene

id, name, commonEffects→commonEffects, aroma, boilingPoint→boilingPointC, displayOrder→ext.pufftab.displayOrder. (No timestamps on the iOS entity.) These are seeded reference rows; exporters SHOULD export only rows the user edited or that are referenced, and importers merge by name case-insensitively when ids differ (N4). The reference exporter implements the referenced-rows half only (reliably detecting “edited” seeded rows is impractical).

9. TaxRate → catalog tax-rate

id, name, rate, isDefault→default, sortOrder→ext, createdAt/updatedAt→created/updated. The records themselves have always been in the legacy export (TaxRateExport); only the two timestamps were absent and are carried going forward.

10. UserPreferences → apps/pufftab/settings.json

The singleton (id …0001) is app configuration, not consumption data — it moves wholesale to apps/pufftab/settings.json (journal-tier under the Sealed profile). All fields export, including the previously-unexported puffTrackingEnabled, accentColorName, trackPuffLocation. Three transforms:

11. SupplyTerpene (junction) → embedded terpenes[]

Each row becomes { "name": <terpene.name>, "percentage": <percentage> } embedded in the supply. The junction row’s own id and the terpeneId are dropped (recreatable; the dictionary merge key is name — N4). If strict junction-id round-trip is ever needed, ext.pufftab.terpeneLinks may carry [{id, terpeneId}]; the reference exporter does not.

Junction rows whose terpene reference is nil or unresolvable (CloudKit partial sync, deleted dictionary row) cannot produce the required {name, percentage} embed: preserve their percentages as ext.pufftab.orphanedTerpenes: [1.25, …] — an array of scalars, since the Core §6.2 ext profile forbids nested objects; exporters MUST NOT fail on them.

Known iOS bug (must fix before conformance): the “Compressed” export path hard-codes "supplyTerpenes": [] (DataTransferView.swift ≈ line 1203), silently dropping all terpene links.

12. SupplyTaxRate (junction) → taxes[]

Each row contributes its taxRate.id to the supply’s taxes array. Junction id dropped (no payload, fully recreatable). Rows whose taxRate reference is nil or unresolvable are skipped with a warning (a tax link with no rate carries no information); exporters MUST NOT fail on them.

13. DailyLimitHistory → apps/pufftab/daily-limit-history.jsonl

Immutable per-day rows (date startOfDay, limitGrams, consumedGrams, puffCount, isLimitEnabled, timestamps) are PuffTab’s materialized feature history — consumedGrams/puffCount are derivable from consumption events, but limitGrams-as-of-that-day is not (the app never recorded limit changes). Export verbatim as JSONL, one object per day: {"date": "2026-07-09", "limitGrams": 1.0, "consumedGrams": 0.7, "puffCount": 3, "enabled": true, "created": …, "updated": …}. date is date-only and identifies a calendar day: importers MUST dedupe by calendar day (not by instant) and reconstruct the stored Date as local-timezone startOfDay — the iOS row stores a local startOfDay instant that differs across timezones. Future iOS versions SHOULD additionally emit a pufftab.limit-changed extension journal event on change, making this file fully derivable.

14. Photos (all Data fields) → attachments

Every photo/logo blob: hash (SHA-256) → write attachments/<hh>/<sha256>.<ext> → reference "sha256:<hex>", with <ext> sniffed from magic bytes (jpg/png/webp/heic, bin fallback) — never assumed.

For full-vault backups, the iOS exporter defaults to Preserve original photo files: it exports stored bytes verbatim, independently of the photo-capture compression setting. The export UI explicitly discloses that embedded metadata is retained (the keep-originals choice in Core §7). Users can turn that option off for a smaller export, which recompresses via ImageCompressor using the configured quality and maximum pixel dimension and strips EXIF, including GPS, from recompressed images. If an image cannot be decoded, its bytes are retained unchanged.

Single-supply shares retain their separate compression/privacy policy: recompress when compressImages is enabled; when disabled, keep the stored original bytes. Hashes always cover the actual exported bytes. Importers preserve attachment bytes; they MUST NOT silently recompress them. This policy avoids cumulative image-quality loss over full-backup/restore cycles and does not change the CanPUFF version or attachment format.

Base64-embedded photos in JSON exports are prohibited in CanPUFF interchange — this replaces the legacy 850 MB single-JSON problem structurally.

15. Out of scope

The seven @Model classes not registered in the SwiftData schema (Session, QuickPuff, PuffTemplate, DailyPuffAverage, UsageAnalytics, AppIntentConfig, IntentData) are unpersisted dead code and have no mapping. @AppStorage/UserDefaults state (hasVerifiedAge, use24HourTime, iCloud toggle, widget preferences) is device-local configuration and intentionally unmapped (a future apps/pufftab/device-settings note may carry use24HourTime if users ask).

16. Legacy import (the existing “1.x” JSON export)

PWA and future iOS versions MUST be able to ingest the current single-JSON export (streaming parse only — the file can exceed device memory): map per this document, lowercase UUIDs, convert base64 photos to attachments, flatten greenHoursScheduleData, migrate deprecated shop fields, and tolerate the known quirks (import requiring non-empty shops; version prefix "1."; the compressed-export terpene bug). Two legacy-only preference fields need dispositions: userPreferences[].use24HourTime (device-local) maps to apps/pufftab/settings.json as use24HourTime; userPreferences[].currencyCode (legacy global currency) maps to settings.json as legacyCurrency — per-supply currency remains authoritative. Legacy export is import-only; nothing writes it going forward.

Notes

September 23, 2026 cannabinoid migration

Native Cannabinoid/SupplyCannabinoid records map to the v1 cannabinoid dictionary and supply concentration collection. Legacy percent fields remain stored for older databases; a supply without an authored collection reads them through a multiply-by-10 adapter. An authored empty collection suppresses legacy fallback. Both concentration readings are optional. Cultivar/lineage map directly; tip uses the supply currency and is included once after tax. Original THC/CBD snapshots are preserved as originalUnspecifiedMgPerGram, never reinterpreted as as-sold readings.

The optional native receiptNumber maps directly to the supply’s receiptNumber text field, preserving leading zeroes and letters. It is omitted from privacy-trimmed sharing unless purchase details are explicitly included.

The native Type picker and the PWA Type selector edit the same optional variety field. Both present Cultivar / strain and Lineage as optional product details, separate from the main supply name.

The optional native Cannabinoid.descriptionText maps to cannabinoid.description; notes remain separate. An absent description may use the default full names for THC/CBD. Unlike the general empty-string omission rule, an explicitly cleared description exports as description: "" to preserve that choice.

October 8–10, 2026 pre-rolls

Maps the pre-roll fields of Core §5.1.1. Flower writes none of them, so existing vaults and exports are byte-identical.

iOS CanPUFF
Supply.categoryRaw category (Core §5.1.1). Written verbatim when non-empty and read back verbatim, so a category from a later revision survives a round trip. The app treats an unrecognised value as flower for display only.
Supply.formatText format
Supply.unitGrams unitGrams (pre-rolls: the supply stays mass-based and keeps grams/gramsStart)

Edibles. PuffTab does not track edibles, so it does not implement the Edibles profile. On import, a supply with category: edible or unit: each is skipped, and so is every consumption event that carries serving or points at such a supply. Nothing is converted to grams, and the import summary reports how many supplies and doses were skipped. Skipped records are not kept, so a vault that PuffTab exports afterwards no longer contains them.