abbu 0.4.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e93fb2c992d75b467a430083bd474aef632c6f759ae858653e9b3ed44e261cc9
4
- data.tar.gz: f346d67e1789116120ec804881b0f9660d6853c55102557e084d8be78184a0cf
3
+ metadata.gz: 71bd4536cf0e22f9bf9177d5715f4210a84a3b50bcab562266fe4d71c24d831a
4
+ data.tar.gz: 1bce6e9c3022f814f7ae789c232e8f0d62fe17999cca109cdba16825cea5a7f8
5
5
  SHA512:
6
- metadata.gz: ae3d1565ac2203067a1e2da4cb304eba8b112bfbd94b78f65c0a39a58f6a007835da1929fcfbc58cea13fdde43ac23eae1338b77fcf4466b31a2a2f40f31a3a6
7
- data.tar.gz: 6a904be665a31a2254c139aced555dfcfd92af6612bec463ad776e5e99f939210954625c5141b17ee095518b0bd85d29f9dbfa795ec0f0ac34a9b8dd8231c6b0
6
+ metadata.gz: 378d928ea9d82de3f5ddb54e8f4d9b61e8b14a9cea61225248394e4d665bc8a5c7263a8292af2089acb879b6a0d600dd532fa9a288c5025b36de9c000fab6107
7
+ data.tar.gz: 573617afdbedd7af5a415dd08f0fb1b2c330d8ccc1bc4d798f0a9febc38eb455b83e292a4c3f25dd30728770fe56a0b97cb65c1acd1e9a13e59df7be43377042
data/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  # abbu
4
4
 
5
5
  Read-only Apple Contacts toolkit for `.abbu` archives and opt-in live macOS stores.
6
- Version 0.4.0 adds querying, diagnostics, identity evidence, and safe image extraction
7
- alongside CSV, JSON, and vCard export. The public API remains pre-1.0.
6
+ Development version 0.8.0 strengthens vCard serialization alongside source-scoped groups, timestamp
7
+ queries, diagnostics, identity evidence, image extraction, and CSV/JSON/vCard export.
8
+ The public API remains pre-1.0.
8
9
 
9
10
  ## Features
10
11
 
@@ -77,11 +78,14 @@ live_contacts = Abbu.open_live("/path/to/AddressBook").contacts
77
78
  Live databases are opened with SQLite's read-only mode. The process may require
78
79
  Full Disk Access under **System Settings → Privacy & Security → Full Disk Access**.
79
80
  Live inputs expose parser `diagnostics` and accept `strict: true` (CLI `--strict`).
80
- The live CLI supports stats, deduplication, and exports; archive-only search, schema,
81
+ The live CLI supports source/group listing, stats, deduplication, and exports; archive-only search, schema,
81
82
  and image-extraction options are rejected explicitly.
82
83
 
83
- Live access does not write Contacts databases. WAL/concurrent-writer consistency
84
- remains unverified ([#28](https://github.com/scarver2/abbu/issues/28)).
84
+ Live access does not write Contacts databases. Synthetic WAL tests verify committed
85
+ data visibility and read-only access, but a read is **not an atomic snapshot**:
86
+ separate contact/relationship queries can observe different commits, and databases
87
+ are read independently. A LiveStore caches its first contact result; reopen it to
88
+ refresh. See [live-store consistency evidence](docs/ABBU.md#wal-and-concurrent-writer-evidence).
85
89
 
86
90
  Labeled values expose a normalized `label` for display and retain the source
87
91
  value in `raw_label`. For example, `_$!<Mobile>!$_` becomes `Mobile` while the
@@ -104,6 +108,129 @@ Lookup methods return every match as an `Abbu::Query`; they never silently pick
104
108
  one contact when the same identifier appears in multiple sources. Returned
105
109
  contacts retain their parser-provided source provenance.
106
110
 
111
+ ### Timestamp queries
112
+
113
+ ```ruby
114
+ archive.query.modified_since('2026-09-01T00:00:00Z').search('stan')
115
+ archive.query.date_range(:created_at, since: '2026-09-01T00:00:00Z',
116
+ before: '2026-10-01T00:00:00Z')
117
+ # Also works with explicitly opened live data:
118
+ Abbu::Query.new(Abbu.open_live.contacts).created_since(Time.utc(2026, 9, 1))
119
+ ```
120
+
121
+ Bounds accept `Time` or full ISO 8601 strings with seconds and an explicit `Z`
122
+ or numeric offset. Start (`since`) is inclusive; end (`before`) is exclusive.
123
+ At least one bound is required, and start must precede end. Invalid bounds raise
124
+ `ArgumentError`, including on empty queries. Missing timestamps never match.
125
+ Filters preserve source records and compose with other Query criteria. These are
126
+ observed storage timestamps, not proof of human edits or a complete change feed;
127
+ deletions and changes without timestamps cannot be discovered this way.
128
+
129
+ ```bash
130
+ abbu Contacts.abbu --modified-since 2026-09-01T00:00:00Z --json
131
+ abbu Contacts.abbu --created-since 2026-09-01T00:00:00Z --created-before 2026-10-01T00:00:00Z --search stan --json
132
+ ```
133
+
134
+ All four `--created-since`, `--created-before`, `--modified-since`, and
135
+ `--modified-before` filters can be combined (AND). They use existing search
136
+ TSV/JSON output: status 0 for matches, 1 for no matches, and 2 for invalid bounds
137
+ or incompatible output options. Diagnostics stay on stderr. CLI timestamp queries
138
+ are archive-only, like existing search; use the Ruby Query API for live inputs.
139
+ Do not combine timestamp queries with export, stats, schema, dedupe, or extraction.
140
+
141
+ ### Sources
142
+
143
+ ```ruby
144
+ sources = archive.sources # Frozen Array<Abbu::Source>, sorted by relative_path
145
+ source = sources.first
146
+ source.relative_path # "." for root, or "Sources/<observed identifier>"
147
+ source.identifier # Raw directory identifier, or nil for root
148
+ source.provider # nil: never inferred from paths or identifier spelling
149
+ source.files # Frozen file-level provenance descriptors
150
+ source.contacts.search('stan') # Query over original contacts, no automatic deduplication
151
+ source.group_names # Sorted unique observed membership labels, not group identities
152
+ source.to_h # Metadata, files, contact_count, group_names; no contact payload
153
+
154
+ Abbu.open_live('/path/to/AddressBook').sources # Same API, read-only connections
155
+ ```
156
+
157
+ Sources are observed input containers, not verified Apple account identities.
158
+ Existing `contact.source` hashes are unchanged. Membership is based on the exact
159
+ file provenance path; repeated contact IDs and filenames in different sources
160
+ never merge. Files from the same observed source directory form one container.
161
+ An empty discovered database still appears; an empty archive returns `[]`.
162
+ Plist records outside `Sources/` form the root container. Archives retain their
163
+ existing SQLite-first parser selection: ignored plist files are not listed.
164
+
165
+ Source metadata, file descriptors, membership arrays, and group-name strings are
166
+ frozen snapshots. Contacts remain the original mutable objects. `sources` eagerly
167
+ parses contacts, honors diagnostics/strict mode, and is cached; reopen the input to
168
+ refresh. It does not add live-store atomic snapshot guarantees. Group names cover
169
+ observed contact memberships only, not empty groups or same-name group identity.
170
+
171
+ ```bash
172
+ abbu Contacts.abbu --sources
173
+ abbu --live-path /path/to/AddressBook --sources --json
174
+ ```
175
+
176
+ `--sources` always emits a JSON array to stdout; `--json` is optional. Each object
177
+ has `path`, `relative_path`, `kind`, `identifier`, `provider`, `files`,
178
+ `contact_count`, and `group_names`; unknown `provider` and root `identifier` are
179
+ explicit JSON nulls. `files` uses the existing four-key contact provenance schema.
180
+ Source/file ordering is by relative path; contact order is parser order within
181
+ those sorted files. Listings exit 0 even when empty. Conflicting operations or
182
+ strict parse failures exit 2, live input/access failures exit 1, and diagnostics
183
+ stay on stderr. Only `--strict` and `--json` may accompany this operation besides
184
+ input selection. Paths, raw identifiers, and group names may be sensitive;
185
+ do not publish source listings as sanitized logs.
186
+
187
+ ### Groups
188
+
189
+ ```ruby
190
+ group = archive.groups.first # Frozen Array<Abbu::Group>
191
+ group.record_id # Observed SQLite group key, local to group.source[:path]
192
+ group.name # Original name, including whitespace/Unicode; may be nil
193
+ group.source # Frozen file-level provenance, including source identifier
194
+ group.contacts.search('stan') # Query over the original mutable contacts
195
+ archive.groups_for(archive.contacts.first) # Reverse lookup, without changing Contact#groups
196
+ archive.sources.first.groups # Same objects, scoped to that source
197
+ archive.query.where(company: 'Acme Corp').in_group(group)
198
+ Abbu.open_live('/path/to/AddressBook').groups # Same API, read-only
199
+ ```
200
+
201
+ Groups are identified by database path plus observed record key, never by name.
202
+ Same-name groups within a file and repeated keys across files/sources remain
203
+ distinct. `Contact#groups` remains the original array of labels (including duplicate
204
+ or null names); `Contact#group_memberships` additionally retains `{ record_id:, name: }`
205
+ for each observed join row, including duplicates. Group contacts list each original
206
+ contact once. Existing contact JSON/CSV/vCard output is unchanged.
207
+
208
+ Only groups reached by supported contact membership joins are listed. Empty or
209
+ unreferenced groups, dangling joins, and legacy plist group relationships are not
210
+ enumerated; no semantics are inferred for them. Missing optional membership tables
211
+ retain tolerant diagnostics and strict-mode errors. Ordering is source relative
212
+ path, file relative path, then numeric record key; contacts retain parser order.
213
+
214
+ Metadata and membership snapshots are frozen when sources/groups are first built;
215
+ contacts themselves remain mutable. Later edits to contact labels/evidence do not
216
+ rewrite these snapshots. `groups_for` and `Query#in_group` use original contact
217
+ object identity: a contact freshly parsed by another input instance does not
218
+ belong to this snapshot, even for the same path. Reopen to refresh; live reads
219
+ retain the consistency limitations described above.
220
+
221
+ ```bash
222
+ abbu Contacts.abbu --groups
223
+ abbu --live-path /path/to/AddressBook --groups --json
224
+ ```
225
+
226
+ `--groups` emits a JSON array of `record_id` (integer), `name` (string or null),
227
+ `source` (the four-key file provenance hash), and `contact_count` (integer).
228
+ `Group#to_h` uses the same schema. Names are never normalized. Empty results exit 0;
229
+ strict failures and conflicting operations exit 2; live access failures exit 1.
230
+ Only input selection, `--strict`, and optional `--json` may accompany this mode;
231
+ `--sources` and `--groups` cannot be combined. Diagnostics stay on stderr.
232
+ Group names, IDs and source paths may be sensitive; this is not sanitized logging.
233
+
107
234
  ### Export
108
235
 
109
236
  ```ruby
@@ -117,6 +244,34 @@ Abbu::Exporters::JsonExporter.new(archive.contacts).to_file("contacts.json")
117
244
  Abbu::Exporters::VcardExporter.new(archive.contacts).to_file("contacts.vcf")
118
245
  ```
119
246
 
247
+ vCard output uses UTF-8, CRLF endings (including the final line), escaped TEXT
248
+ values, and folding at no more than 75 bytes without splitting UTF-8 characters.
249
+ Repeated fields retain order. `N` and `ADR` escape each component independently.
250
+ Text newline variants become vCard `\\n`; URL/IM URI values use percent encoding
251
+ instead of TEXT escaping. Empty contact input emits no bytes.
252
+
253
+ Labeled emails, phones, addresses, URLs, IM handles, and anniversaries pair with
254
+ `itemN.X-ABLABEL` using the same per-card `itemN` group. The original `raw_label`
255
+ wins over the display `label`, including empty strings. Custom labels no longer
256
+ become arbitrary `TYPE` parameters. Only exact ASCII standard type names are
257
+ recognized, case-insensitively; email/phone defaults are `INTERNET`/`VOICE`.
258
+ For example, `Mobile` is preserved as a label, not guessed to mean `CELL`.
259
+ No preference is invented for anniversaries. Existing photo file URIs remain;
260
+ embedded photos are separate work.
261
+
262
+ Migration from 0.7: consumers must unfold CRLF continuations before parsing,
263
+ decode TEXT escapes, and accept grouped property names rather than matching
264
+ literal lines such as `EMAIL;TYPE=Work`. The exporter signatures are unchanged.
265
+ Unsupported controls, invalid UTF-8, unsafe social-service parameter tokens,
266
+ or invalid IM service schemes fail explicitly before writing output; export
267
+ does not sanitize or mutate Contact evidence. Other encoding conversions may
268
+ raise Ruby encoding errors. Existing files are overwritten on a successful
269
+ `to_file`, as before; this is not an atomic/no-clobber writer.
270
+
271
+ See [vCard evidence and compatibility limits](docs/ABBU.md#vcard-serialization-evidence)
272
+ for the standards basis and Apple-specific audit. Synthetic round-trip tests
273
+ are not proof of import fidelity in every Apple Contacts release.
274
+
120
275
  ### Duplicate Detection
121
276
 
122
277
  ```ruby
@@ -209,9 +364,15 @@ rake abbu:stats[Contacts.abbu]
209
364
  See [`docs/ABBU.md`](docs/ABBU.md) for a full explanation of the archive structure,
210
365
  SQLite table schema, and format history.
211
366
 
367
+ The [compatibility regression matrix](docs/FORMAT_COMPATIBILITY.md) protects
368
+ legacy XML and varied SQLite layouts in every CI run, and distinguishes tested
369
+ synthetic shapes from unverified macOS/Contacts releases.
370
+
212
371
  ## Roadmap
213
372
 
214
373
  See [`docs/TODO.md`](docs/TODO.md) for the full release schedule and feature checklist.
374
+ The [pre-1.0 API stability gate](docs/API_STABILITY.md) inventories supported
375
+ surfaces, evidence gaps, compatibility policy, and required release-readiness checks.
215
376
 
216
377
  ## Ruby Compatibility
217
378
 
data/bin/abbu CHANGED
@@ -59,6 +59,14 @@ parser.on('--schema', 'Print SQLite schema diagnostics as JSON') do
59
59
  options[:schema] = true
60
60
  end
61
61
 
62
+ parser.on('--sources', 'List observed source containers as JSON (archive or live)') do
63
+ options[:sources] = true
64
+ end
65
+
66
+ parser.on('--groups', 'List observed groups with memberships as JSON (archive or live)') do
67
+ options[:groups] = true
68
+ end
69
+
62
70
  parser.on('--email ADDRESS', 'Find contacts by exact normalized email') do |address|
63
71
  options[:email] = address
64
72
  end
@@ -67,10 +75,21 @@ parser.on('--phone NUMBER', 'Find contacts by exact normalized phone number') do
67
75
  options[:phone] = number
68
76
  end
69
77
 
70
- parser.on('--json', 'Emit search results as structured JSON instead of TSV') do
78
+ parser.on('--json', 'Emit search results as JSON (optional for --sources or --groups)') do
71
79
  options[:json] = true
72
80
  end
73
81
 
82
+ timestamp_bounds = %w[since before]
83
+ %w[created modified].each do |kind|
84
+ timestamp_bounds.each do |bound|
85
+ parser.on("--#{kind}-#{bound} TIMESTAMP", "Filter #{kind} timestamp (ISO 8601 with timezone)") do |value|
86
+ options[:date_filters] ||= {}
87
+ options[:date_filters][:"#{kind}_at"] ||= {}
88
+ options[:date_filters][:"#{kind}_at"][bound.to_sym] = value
89
+ end
90
+ end
91
+ end
92
+
74
93
  parser.on('-v', '--version', 'Print version') do
75
94
  puts "abbu #{Abbu::VERSION}"
76
95
  exit
@@ -123,12 +142,33 @@ at_exit do
123
142
  end
124
143
 
125
144
  begin
126
- if live_mode && options.values_at(:extract_images, :schema, :search, :email, :phone, :json).any?
127
- warn '--live supports --stats, --dedupe, --format, and --strict; archive-only options are not supported.'
145
+ listing = %i[sources groups].select { |key| options[key] }
146
+ if listing.any? && (listing.length > 1 || options.values_at(:extract_images, :schema, :search, :email, :phone,
147
+ :date_filters, :stats, :dedupe, :format, :output).any?)
148
+ raise ArgumentError,
149
+ "--#{listing.first} cannot be combined with other operations; only --json and --strict are supported"
150
+ end
151
+
152
+ if live_mode && listing.empty? &&
153
+ options.values_at(:extract_images, :schema, :search, :email, :phone, :json, :date_filters).any?
154
+ warn '--live supports --sources, --groups, --stats, --dedupe, --format, and --strict; ' \
155
+ 'archive-only options are not supported.'
128
156
  exit 1
129
157
  end
158
+ if options[:date_filters]
159
+ if options.values_at(:extract_images, :schema, :stats, :dedupe, :format, :output).any?
160
+ raise ArgumentError, 'timestamp filters cannot be combined with export, stats, schema, dedupe, or extraction'
161
+ end
162
+
163
+ options[:date_filters].each { |field, bounds| Abbu::TimestampRange.new(field, **bounds) }
164
+ end
130
165
  archive = live_mode ? Abbu.open_live(live_path, strict: options[:strict]) : Abbu.open(file, strict: options[:strict])
131
166
 
167
+ if listing.any?
168
+ puts JSON.pretty_generate(archive.public_send(listing.first).map(&:to_h))
169
+ exit 0
170
+ end
171
+
132
172
  if options[:extract_images]
133
173
  result = archive.extract_images(options[:extract_images])
134
174
  puts "Extracted #{result.files.count} image(s) to #{File.expand_path(options[:extract_images])}"
@@ -149,20 +189,26 @@ begin
149
189
  exit 1
150
190
  end
151
191
 
152
- if options[:json] && search_options.empty?
153
- warn '--json requires --search, --email, or --phone.'
192
+ if options[:json] && search_options.empty? && !options[:date_filters]
193
+ warn '--json requires --search, --email, or --phone (or a timestamp filter).'
154
194
  exit 1
155
195
  end
156
196
 
157
- if search_options.any?
197
+ if search_options.any? || options[:date_filters]
158
198
  results = if options[:search]
159
199
  archive.search(options[:search])
160
200
  elsif options[:email]
161
201
  archive.find_by_email(options[:email])
162
- else
202
+ elsif options[:phone]
163
203
  archive.find_by_phone(options[:phone])
204
+ else
205
+ archive.query
164
206
  end
165
207
 
208
+ options.fetch(:date_filters, {}).each do |field, bounds|
209
+ results = results.date_range(field, **bounds)
210
+ end
211
+
166
212
  if options[:json]
167
213
  Abbu::Exporters::JsonExporter.new(results).to_stdout
168
214
  else
@@ -218,7 +264,7 @@ begin
218
264
  rescue Abbu::LiveStore::Error => e
219
265
  warn "abbu: #{e.message}"
220
266
  exit 1
221
- rescue Abbu::ParseError => e
267
+ rescue Abbu::ParseError, ArgumentError => e
222
268
  warn "abbu: #{e.message}"
223
269
  exit 2
224
270
  end
data/docs/ABBU.md CHANGED
@@ -125,13 +125,66 @@ Every parsed contact includes source provenance with the absolute source path, i
125
125
  relative to the `.abbu` root, and whether it came from the root bundle or a database under
126
126
  `Sources/<identifier>/`. Legacy plist contacts receive the same file-level provenance.
127
127
 
128
+ ### Source container evidence
129
+
130
+ `Archive#sources` and `LiveStore#sources` group the existing `SourceDescriptor`
131
+ file evidence into root (`.`) and observed `Sources/<identifier>` containers.
132
+ This is ABBU's grouping convention over input paths, not discovery of Apple's
133
+ private account/container tables. It introduces no new storage-column inference.
134
+ Every selected input file appears, including databases with no contacts; files
135
+ in the same observed container are retained individually. Legacy plist files
136
+ outside `Sources/` belong to root. Parser selection and discovery scope are unchanged.
137
+
138
+ `Source#identifier` preserves the observed directory spelling, while `provider`
139
+ remains nil even for a directory named `iCloud`. Container paths are local identities
140
+ within that opened input, not globally stable IDs across moved archives or snapshots.
141
+ Contacts link through their original file-level `source[:path]`; the source hash
142
+ is neither replaced nor enriched with speculative provider metadata. `group_names`
143
+ reports distinct membership strings only; the group API below preserves identity.
144
+
145
+ `spec/abbu/source_spec.rb` copies the existing deterministic root database into
146
+ root, `Sources/iCloud`, and `Sources/Équipe`, including two database filenames in
147
+ one container and colliding contact IDs across containers. These fixtures verify
148
+ grouping, provenance preservation and unknown-provider behavior, not an Apple
149
+ account schema. Source listing parses/caches contacts and retains the existing
150
+ strict/tolerant diagnostics and live consistency limitations below.
151
+
152
+ ### Group membership evidence
153
+
154
+ The existing synthetic fixture joins `Z_ABCDCONTACTGROUP.Z_GROUP` to
155
+ `ZABCDRECORD.Z_PK` and filters memberships by `Z_CONTACT`. The group parser now
156
+ retains that joined key alongside the exact `ZFIRSTNAME` value instead of discarding
157
+ the key. `Contact#group_memberships` preserves every returned join row; the existing
158
+ `Contact#groups` label array and exporters are unchanged. No label normalization
159
+ or inference from other column names is introduced.
160
+
161
+ `Archive#groups`, `LiveStore#groups`, and `Source#groups` aggregate this evidence by
162
+ absolute database path and record key. Keys are file-local, not global Apple IDs.
163
+ `Group#source` is file provenance; its `identifier` connects to the observed source
164
+ container. Group contacts retain original object identities with repeated joins
165
+ collapsed only in the navigable contact set, not in raw membership evidence.
166
+ Reverse lookup uses `groups_for(contact)` on the input or source; `Query#in_group`
167
+ intersects the caller's contacts with that snapshot without name-based matching.
168
+
169
+ `spec/abbu/group_spec.rb` extends the deterministic fixture with duplicate joins,
170
+ same-name/different-key groups, null and Unicode names, and repeated keys in root
171
+ and multiple files under `Sources/Équipe`. These establish collision boundaries
172
+ and name preservation through model and JSON output, not a new Apple schema.
173
+ No Apple version is asserted for this synthetic variation.
174
+
175
+ Only joined groups with parsed contacts are represented. Empty/unreferenced rows,
176
+ dangling joins, and plist group relationships remain unsupported; their semantics
177
+ need separate evidence. Missing membership tables retain existing diagnostics and
178
+ strict-mode behavior. Building groups adds no database reads beyond contact parsing
179
+ and makes no stronger live snapshot guarantee. JSON listings expose raw names and
180
+ file paths and must be treated as sensitive.
181
+
128
182
  ### Opt-in live Contacts stores
129
183
 
130
184
  The CLI uses `--live` only for auto-discovery and `--live-path PATH` for an explicit
131
185
  store. These forms are mutually exclusive and accept no positional archive/path arguments.
132
- Option ordering does not change input selection. Synthetic WAL-mode and concurrent-writer
133
- validation remains a follow-up; read-only handles do not establish snapshot consistency
134
- across an actively changing Contacts store.
186
+ Option ordering does not change input selection. Read-only handles do not establish
187
+ snapshot consistency across an actively changing Contacts store.
135
188
 
136
189
  `Abbu.open_live` and the CLI's live modes can read an AddressBook directory
137
190
  without first exporting an `.abbu` archive. This mode is deliberately separate from
@@ -156,6 +209,41 @@ automatic discovery without a caller-supplied path raises
156
209
 
157
210
  The repository verifies live-store behavior only with deterministic synthetic SQLite
158
211
  fixtures. It does not inspect or commit a developer's real Contacts store.
212
+
213
+ ### WAL and concurrent-writer evidence
214
+
215
+ `spec/abbu/live_store_wal_spec.rb` copies the existing synthetic root database into
216
+ a temporary directory and opens a separate writer connection in WAL mode. No
217
+ real Contacts data or macOS privacy permission is required. The tests deliberately
218
+ keep the writer open, disable its automatic checkpoint, and interleave operations
219
+ at known boundaries instead of using sleeps or timing races.
220
+
221
+ The fixture demonstrates that ABBU reads committed WAL changes while the writer
222
+ remains connected. Its database and WAL bytes stay unchanged across the read,
223
+ all ABBU connections use `readonly: true`, and traced statements contain no writes
224
+ or checkpoint requests. An uncommitted writer transaction is not visible. After
225
+ commit, a new LiveStore sees the new value; an existing store retains its cached
226
+ contacts.
227
+
228
+ There is an important consistency limit: the parser does not enclose all queries
229
+ in a read transaction. A commit between the contact-row SELECT and an email SELECT
230
+ can produce an old name with a new email from the same database. SQLite's snapshot
231
+ isolation applies within a read transaction, not across ABBU's independent
232
+ statements. Multiple database files are also read independently; there is no
233
+ cross-database snapshot guarantee. These tests characterize existing behavior,
234
+ not a new snapshot API or a guarantee for every Contacts/SQLite version.
235
+
236
+ SQLite uses `-wal` and `-shm` sidecars for WAL operation. Read-only database access
237
+ does not promise that shared-memory lock/index state is byte-for-byte unchanged;
238
+ the tests intentionally do not make that claim. Do not remove sidecars, checkpoint
239
+ a live Contacts database, or copy only its main file to try to obtain a snapshot.
240
+ Use an independently verified consistent export/backup for snapshot-sensitive work.
241
+ Missing/inaccessible sidecar and filesystem-lock behavior remain deployment-specific
242
+ limitations, not behavior established by the writable temporary fixture.
243
+
244
+ References: [SQLite WAL](https://www.sqlite.org/wal.html) and
245
+ [SQLite isolation](https://www.sqlite.org/isolation.html).
246
+
159
247
  ### Provenance-aware identity evidence
160
248
 
161
249
  ABBU treats deduplication as a suggestion boundary rather than proof that two records are
@@ -278,8 +366,63 @@ normalizes into the same contact model used by the SQLite parser. Additional
278
366
  plist keys or layouts require fixture evidence before they are treated as
279
367
  supported semantics.
280
368
 
369
+ ### vCard serialization evidence
370
+
371
+ The 0.8.0 exporter implements TEXT escaping and structured components from
372
+ [RFC 2426 §§2.3–2.6](https://www.rfc-editor.org/rfc/rfc2426), and CRLF/grouping
373
+ and unfolding from [RFC 2425 §5.8.1](https://www.rfc-editor.org/rfc/rfc2425).
374
+ ABBU folds conservatively at 75 **octets**, including the continuation space,
375
+ without splitting a UTF-8 code point. Escaping happens before folding; decoding
376
+ must unfold first. Commas, semicolons and backslashes are escaped in TEXT;
377
+ CRLF, bare CR and LF become the logical newline escape. This preserves logical
378
+ text, not the original newline byte convention. Input Contact values are unchanged.
379
+ URI properties use percent encoding, preserving existing escapes and URI
380
+ delimiters rather than applying TEXT rules. This is not a general URI validator.
381
+
382
+ `spec/abbu/exporters/vcard_exporter_fidelity_spec.rb` contains deterministic
383
+ inline wire fixtures and an independent fixture-only decoder. It exercises
384
+ repeated properties, injection-shaped input, structured names/addresses,
385
+ multiline notes, UTF-8 folding boundaries, per-card group numbering, and both
386
+ SQLite/plist → Contact → vCard label preservation. The temporary SQLite fixture
387
+ only varies an already supported `ZLABEL`; the plist fixture uses the existing
388
+ `Email.values[].label` mapping. Neither adds guessed Apple storage semantics.
389
+
390
+ The reviewed extension audit is deliberately bounded:
391
+
392
+ | Surface | Implemented rule / evidence limit |
393
+ | --- | --- |
394
+ | `itemN`, `X-ABLABEL` | Standard group syntax pairs repeated properties with ABBU's existing raw-label extension. Labels remain exact after TEXT decoding; numbering is local to each card, not a stored Apple ID. No Apple import validation is claimed. |
395
+ | `X-ABDATE` | Existing anniversary mapping retained, now grouped with its label. Removed the fabricated `type=pref`. Other date collections remain unsupported by this exporter. |
396
+ | `TYPE`, `PREF` | Only exact ASCII names from RFC 2426's EMAIL/TEL/ADR lists and RFC 4770's IMPP list are recognized from display labels. No whitespace trimming, Unicode folding, custom-label tokenization, or Mobile→CELL guess. Raw labels remain separately available. Explicit `PREF` is recognized; no priority is inferred from row order. |
397
+ | `UID` | Not generated: SQLite keys and source paths are not demonstrated global contact identifiers. |
398
+ | `IMPP` | URI-valued property per [RFC 4770](https://www.rfc-editor.org/rfc/rfc4770). Existing service-to-scheme convention retained with scheme syntax checks and address encoding. A syntactically valid scheme does not prove service interoperability; absent service retains legacy `unknown:`. |
399
+ | `X-SOCIALPROFILE` | Existing service/username extension retained; parameter service must be an ASCII token and username is escaped TEXT. No provider URL or Apple import semantics are inferred. |
400
+ | `ADR` | Seven components, each independently escaped. No new PO box/extended-address storage mapping. Missing label no longer fabricates HOME. |
401
+ | Partial/lunar dates, phonetic names, verification code | Existing extensions retained, not certified as standard vCard 3.0 date forms or Apple alternate-calendar semantics. |
402
+ | `PHOTO` | Existing local file URI retained; portable embedding belongs to #30. |
403
+
404
+ Unsafe parameter values and control characters fail rather than create extra
405
+ properties or silently discard evidence. Serialization completes before file
406
+ opening/stdout emission, so validation failures produce no partial export and
407
+ leave an existing output file untouched. Filesystem failures after opening can
408
+ still leave partial files. Exports contain sensitive contact values and photo
409
+ paths; callers must choose appropriate destinations and permissions.
410
+
411
+ Email preference uses `TYPE=INTERNET,PREF`, retaining the default address type
412
+ as required by RFC 2426's email parameter grammar; TEL includes the standard
413
+ `PCS` token. Unknown extension/registered type names are preserved as labels,
414
+ not asserted to be registered by ABBU's deliberately bounded built-in list.
415
+
416
+ These are standards-backed serialization guarantees and synthetic regression
417
+ observations, not a full-fidelity ABBU backup or certification against a specific
418
+ macOS/Contacts build. A sanitized real Apple export/import corpus remains a
419
+ separate compatibility gate before broader claims.
420
+
281
421
  ## Repository Evidence
282
422
 
423
+ - [Compatibility regression matrix](FORMAT_COMPATIBILITY.md): always-on XML,
424
+ sparse/complete SQLite, root/source/mixed layout profiles and their explicit
425
+ historical evidence gaps. Filename suffixes are not macOS version guarantees.
283
426
  - `spec/fixtures/TestContacts.abbu/` exercises the supported synthetic SQLite,
284
427
  nested source, and image-resolution behavior.
285
428
  - `spec/fixtures/PlistContacts.abbu/` exercises the supported synthetic legacy