Repository navigation
Releases: splitleafhq/aethergram
Release list
v2.1.0
Closes #52.
No API or payload change from 0.5.0: nothing in the API list moved, and every signal carries the
fields and the values it carried there, sdk.version included. What changes is what the number
is.
One version
sdk.versionis the package's release version. It was a separate payload version, which 0.5.0
moved to 2.1.0, so a dashboard read "Aethergram 2.1.0" for a package whose newest tag was
v0.5.0. Host builds already send 2.1.0 and a version a reader has seen cannot go backward, so
the package's numbering resumes there. No 1.x release exists.- Every release after this one moves the stamp, one that changes only behaviour included. A
reader grouping onsdk.versionsees a value per release rather than per payload shape, and
this file is where to learn which releases share a shape. This release stamps what 0.5.0
stamped, so a reader cannot tell those two apart. STABILITY.md keeps what each
release before this one stamped. - The package leaves 0.x, so the major position is in play: a removal or a signature change in
the API list is a major release, where a 0.x minor could carry one. - A payload field the package stops attaching, or sends in a new form, is a major release, a new
one is a minor, and a corrected value that keeps its key and form is a patch. Those were the
payload version's rules and are now the package's. - A host takes this release by editing its manifest.
exact: "0.5.0"stays on 0.5.0, and
from: "0.5.0"stops below 1.0.0. - The install snippets read
from: "2.1.0"rather thanexact:, so a host copying one takes
every 2.x release. Pinningexact:is still open to a host that wants each release on its own
schedule.
Tooling
Scripts/check-version-stamp.shrefuses a tree whoseAethergram.versionis not the version
in this file's top heading, and runs inScripts/run-checks.sh. The release heading check
already holds that heading to the tag, so a tag, its heading, and the stamp agree.
v0.5.0
Closes #50.
A 0.x minor that changes the API list: EnvironmentSnapshot's initializer and stored properties
change, and beginSession() returns a value. The payload version moves to 2.1.0, which adds three
fields and a preset and fixes two values, each checked against TelemetryDeck's SwiftSDK at
58f43629, its docs at 2b9c2108 (the default-parameters reference, basics/acquisition.md, and
articles/decide-to-drop-ios-version.md), and its KotlinSDK at 10b87d4a.
Payload 2.1.0
device.systemMajorVersion, sent asTelemetryDeck.Device.systemMajorVersion: the major OS
version,26. The vendor documents it among its default parameters, its SDK sends it, and its
docs describe the prebuilt system-version chart offering a major-version breakdown beside the
major.minor one.
All three version strings stay bare (26.5.1,26.5,26). The default-parameters reference
types each only as a String, and the vendor's own articles show both forms; its Swift SDK
prefixes the platform where its Kotlin SDK sends the major and major.minor versions bare. A
changed form would split every chart already grouped on the baresystemVersionand
systemMajorMinorVersion, and the new field takes the same form as those two.runContext.language, sent asTelemetryDeck.RunContext.language: the language the app runs in,
the locale's language code.calendar.dayOfWeek, sent asTelemetryDeck.Calendar.dayOfWeek: the local day numbered as ISO
8601 numbers it, Monday 1 through Sunday 7, as the vendor documents it and its SDK sends it.userPreference.languageis the language the user most prefers on the device, the language
subtag of the firstLocale.preferredLanguagesentry, as the vendor defines it. It was the
locale's language, which is the app's, so an app localized only in English reportedenfrom a
device set to German. The key and the form are unchanged.calendar.isWeekendistrueon a Saturday or a Sunday, as the vendor defines it, and is
derived from the day of week. It followed the locale's weekend, so under a Friday-Saturday weekend
a Friday reportedtrueand a Sundayfalse. The key and the form are unchanged.PresetSignal.newInstallDetected,acquisition.newInstallDetected, sent as the vendor's
TelemetryDeck.Acquisition.newInstallDetected. The vendor's docs name that signal as how new
users are detected, sent on first launch. Its SDK sends it whenever a session starts with no
stored session behind it, which is also after 90 days without a session, or after sessions that
each lasted under a second. This package reports it once per retention record, so only after an
install or an erase.
API
SignalRecorder.beginSession()returnstruewhen the call created the retention record, which
makes its session the first counted since install or since the last erase. It is
@discardableResult, so an existing call compiles unchanged. It returnsfalseon every later
session, on a repeat in the same activation, and while collection is not permitted. The session
resetClosingCollection(during:)reopens is counted by that reopen, so no laterbeginSession()
reports it: a host that counts a data reset as a new install records the preset once that call
returns. ARetentionStorewhoseload()returns nil, for a record it could not decode
included, reads as no record, and the next session reportstrue.SignalRecorder.recordNewInstallDetected(parameters:)records the preset, unprefixed like the
other presets. The recorder never records it on its own: a host calls it whenbeginSession()
returnstrue, on the path every other signal takes, so whatever the host applies there covers
it too.EnvironmentSnapshotholds the OS version once, assystemMajorVersion,systemMinorVersion,
andsystemPatchVersion, in place ofsystemVersionandsystemMajorMinorVersion, and derives
all three version fields from it.languagebecomespreferredLanguageandappLanguage. The
initializer takes the new properties.EnvironmentSnapshot.current()takespreferredLanguages:, defaulting to
Locale.preferredLanguages.
Upgrading
- A call to
EnvironmentSnapshot's initializer passes the three version components and the two
languages. Calls tocurrent()andbeginSession()change nothing. - An install whose retention record predates the upgrade already has one, so
beginSession()
never reports it as new. - A host swapping to this package from the vendor's SDK starts every existing install without a
retention record (ADOPTING.md, Phase 6). Its firstbeginSession()after the swap returns
true, and recording the preset on that answer counts every existing install as new on the
migration day. After that, a user returning from 90 days away is not counted as new again, as
the SDK would count them, and the three OS version fields arrive bare where the Swift SDK sent
them with the platform.
Tooling
- A contract suite records a signal through the recorder, encodes it with the adapter, and holds it
to TelemetryDeck's SwiftSDK at58f43629and its default-parameters reference at2b9c2108, as
read on 2026-09-29: the key set, the value forms, the deliberately absent keys and their reasons,
and the install preset's name. The test floor rises to 224. - The consumer fixture compiles a host's activation with the new preset.
v0.4.2
A patch: nothing in the API list moved, and the payload version stays 2.0.0.
Delivery
- A drain that
reset(),resetClosingCollection(during:), or a decline cancelled after it had
finished its delay, but before it took a batch, could still take the queue recorded after that
erase. It then sent from a cancelled task, whichTelemetryDeckTransportreads as retryable, so
the recorder owed a backoff before those signals went out: a draw between 10 and 20 seconds at
the default intervals, during whichflushAndWait()started no pass. A drain now takes a batch
only while it is still the recorder's scheduled drain. The signals stayed queued under the grant
that recorded them, and nothing was sent without consent, before or after this change.
Tooling
- The leak scan's message tier matches co-author and session trailers in any capitalization. It
reads a message as bytes whatever the caller's locale, and exits with an error rather than
reporting clean when it cannot read the message file or either grep over it fails. - Each scanner pattern has a check that fails when that pattern is removed.
- CI checks out with
actions/checkoutv7.0.1.
v0.4.1
Closes #24.
A patch: nothing in the API list moved, and the payload version stays 2.0.0.
Privacy manifest
PrivacyInfo.xcprivacymoved from the core's bundle to the TelemetryDeck adapter's,
Aethergram_AethergramTelemetryDeck.bundle. The core ships no manifest: Apple counts data as
collected when it is transmitted off the device, and the core never transmits.- It declares Product Interaction and Device ID, not linked to the user, not used for tracking, and
collected for analytics, as TelemetryDeck's SDK declares them at 2.14.1. 0.4.0 declared both
linked. The adapter hashes the identifier on the device exactly as the vendor's SDK hashes it, so
the vendor's not-linked declaration holds here too. No tracking, tracking domains, or
required-reason APIs, as in 0.4.0. - Purchase History and Other Diagnostic Data are no longer declared. A host that calls
recordPurchaseCompletedorrecordErrordeclares those types in its own manifest and App Store
answers.
Tooling
- The privacy-manifest check pins the manifest's content and its place in the adapter's bundle,
and is proved refusing a manifest with a linked or an extra type.
v0.4.0
A 0.x minor with no API break: two methods added to SignalRecorder, a platform and a privacy
manifest added, nothing in the API list removed or changed. The payload version stays 2.0.0.
Additions
SignalRecorder.flushAndWait()starts one delivery pass now and waits for it to finish, then for
every queue write submitted by then, the pass's removal included, to reach the store. It starts
no pass, and waits only for the writes, when a retry is already owed, which it never hurries,
when consent is not granted, or while collection is closed for a reset. An erase while it waits
cancels the pass. Cancelling the caller returns it promptly and forfeits the promise that the
writes reached the store; it still takes the recorder's lock first. It promises that pass's
completion, not delivery of everything queued.SignalRecorder.resetClosingCollection(during:)isreset()with collection closed for the
length of the closure. Collection reopens when the last overlapping call returns or throws, and
the reopen opens a counted session if consent permits, wherereset()mints only an uncounted
session identifier. During the closure a decline erases and stands, a grant takes effect at the
reopen,flush()waits for the queue writes only,flushAndWait()starts no pass, andreset()
erases and leaves collection closed.- watchOS 11 is declared, with iOS 18 and macOS 15.
- The core's bundle,
Aethergram_AethergramCore.bundle, shipsPrivacyInfo.xcprivacy: no
tracking, no tracking domains, no required-reason APIs, and Product Interaction, Device ID,
Purchase History, and Other Diagnostic Data collected for analytics, linked to the user and not
used for tracking. A host's own manifest and App Store answers must still reflect what it sends. SignalTransport.sendis spellednonisolated(nonsending)in the protocol rather than left to
the module's feature flag. The requirement means what it meant, and a plainasyncwitness
still conforms.
TelemetryDeck adapter
TelemetryDeckTransportsends over an ephemeral session with no cookie, credential, or URL cache
store by default, where 0.3.2 usedURLSession.shared, so nothing the host's shared session
holds reaches the ingest. A session passed tosession:is used as given.TelemetryDeckConfigurationtraps at construction on abaseURLwhose scheme is nothttps.
0.3.2 accepted any scheme. A development server overhttphas to move tohttps.
Delivery
- A retry after a failure is owed at a random draw between the larger of
transmitIntervaland
half the backoff ceiling, and the ceiling, rather than at the ceiling, so installs that failed
together do not retry together. - A grant onto a non-empty queue restored from a killed process schedules its delivery, rather
than waiting for the next record or flush. The restore reads and decodes the whole queue file on
the granting thread, under the recorder's lock: 1,000 signals, the defaultqueueLimit, measured
about 38 ms cold on a simulator, and 10,000 about 380 ms.
Waiting and erasure
flush(),reset(), and a decline wait for the queue writes submitted before the call, where
0.3.2 waited until the writer went idle, including for writes other threads kept submitting. A
write submitted after the call lengthens the wait by at most one store call.- A purge refused with an error of code 4 from a domain other than Cocoa's is treated as a refused
delete, taking the overwrite and marker path, rather than read as "no file there".
Upgrading
- On resign or background, call
flush(), then runflushAndWait()inside the platform's
expiring-time API,ProcessInfo.performExpiringActivityin an extension orbeginBackgroundTask
in an app, and cancel it when the time expires. ADOPTING.md has both patterns. - Replace a decline, rotate the identifier, re-grant,
beginSession()sequence with
resetClosingCollection { rotate() }, and do not follow it withbeginSession(). A host lock no
longer needs to span the erase.
Known limits
- Every queue write rewrites the whole file, about 819 KB for a full queue at the default 1,000
signals and about 13.6 ms on a Mac. Records that arrive while a write is pending coalesce into
one write. - Overflow eviction and a permanent rejection lose signals, and a failed removal write can resend a
batch: delivery is at least once. - An install from TestFlight with no receipt reports the
devchannel, which lands in the same
test partition asbeta.
Tooling
Scripts/run-checks.shadds an api-break gate against the last release tag, which reads the
release from this file's top heading; the release-heading check; a watchOS build at the floor; a
privacy-manifest check; a consumer fixture build; and a floor on the root suite's test count. It
needs the release tags, so not a shallow clone.- CI runs
Scripts/check-release-heading.shon every push of a tag matchingv*, refusing a tag
this file's top heading does not name with a date.
v0.3.2
Closes #19.
A patch: nothing in the API list moved, and the payload version stays 2.0.0.
Configuration and traps
AethergramConfigurationclampstransmitIntervalandmaxBackoffIntervalto a ceiling far
past any real schedule and below about 9.2e18 seconds, where a sleep that runs traps. An interval
from there crashed the host at the first sleep that ran on it — the coalescing sleep its first
record scheduled wheneverbatchSizeis above 1, or a backoff sleep — and one past about 1.7e20
seconds, whereDuration.secondsitself traps, crashed wherever it was first made aDuration.
That configuration now runs, and the property reads back the clamped value. An interval that is
not finite, or not positive, still traps at construction, as in 0.3.1.queueLimitis not
capped, and one that is not positive still traps there too.TelemetryDeckTransportfails a precondition naming the backgroundURLSessionat the first
send over one. The send aborted the host at the same moment before, with anNSGenericException
about completion handlers that did not name the transport.- Calling back into the recorder from a seam it calls under its lock (
clientUserProvider,
environmentProvider, anyRetentionStorecall,SignalQueueStorage.load()) still terminates
the process, now through a precondition at the recorder entry point that was re-entered rather
than inside the lock's own recursion check.
Consent and erasure
- A pending erase is never superseded by a snapshot submitted after it. The queue writer keeps the
purge and the newest snapshot apart and runs the purge first, so a reset racing a record can no
longer leave a queue the store could not read on disk for a later grant. - A purge never creates the queue file. A decline before anything was granted, in a directory that
refuses the delete, used to be able to write one. - Across an erase, the cancelled send from before it may still be in flight while the drain after
it sends a different batch. The erase takes the drain claim instead of waiting for the cancelled
send to unwind. Sends were never promised to be serial.
Durability
- A queue file that could not be read is retried on every write. Once a read succeeds, what the
file held, up to the newestqueueLimitsignals, is kept ahead of the recorder's queue in every
write, for the next process to load and send. A store wrapped in another conformance keeps the
bound it already has: 1,000, unless it or a copy of it was also given to a recorder directly.
Previously the writes stopped for the life of the process. - The look for the erasure mark checks the mark's reachability and no longer reads file
attributes.
Retention counters and payload
- Day strings (
acquisition.firstSessionDateand the distinct-day history) areyyyy-MM-ddin the
Gregorian calendar, in the time zone of the calendar the recorder was given. A device set to
another calendar used to send its own year, such as a Buddhist year 543 ahead. A stored record
is converted when the recorder loads it: a day that already reads as a Gregorian date between
2015-01-01 and tomorrow is kept, any other is read in the recorder's calendar and kept converted
only if the result lands in that window, and otherwise kept as written. So a rebuilt or
downgraded record is never converted twice. A day written before 0.3.2 under the Ethiopic
calendar's Incarnation-era numbering that also reads as a Gregorian date in that window — any
day from Ethiopic year 2015 on outside the thirteenth month — cannot be told apart from a
Gregorian one and is left as written; one that does not, such as2018-13-01, is read as
Ethiopic like any other. One written under the Chinese or Dangi calendar in a leap month carries
no leap-month flag, so it converts to the same day of the ordinary month before it: 2025-07-25, written as0042-06-01, converts to 2025-06-25. - A session's first recorded signal after
beginSession()is checkpointed, so a session killed
inside the ten-second checkpoint interval is measured to that signal rather than discarded. endSession()closes only a session this recorder opened, and arecordadvances only that
session's checkpoint. A session left open by another process is closed by the next
beginSession()against its own last activity, rather than stretched to now.- For an app extension on iOS or Mac Catalyst (macOS reads no receipt),
EnvironmentSnapshot.current()resolves the receipt thatrunContext.channelis read from
against the containing app's bundle rather than the extension's own, falling back to the
extension's when the app's bundle gives no receipt location. The.appexhas to sit two
directory levels under the.app, as inPlugIns/orExtensions/; any other layout keeps the
extension's own. - A
calendar.hourOfDayvalue outside 0-23, which a caller can pass since caller parameters win,
goes to the wire unchanged instead of trapping the send. - A caller key spelled as a vendor wire name wins over the package key mapped onto that name, on
every run. The winner used to depend on dictionary order.
Scheduling and logging
- The drain runs at utility priority rather than the priority of the call that scheduled it.
- A queue overflow logs once when it begins and once when it ends, rather than on every record. It
ends when a delivery or a permanent rejection leaves the queue below the limit; an erase clears
it without the ending line. A store skipping writes logs when the skipping starts and when it
stops or its reason changes, rather than on every write.
Tooling
- CI builds the package for iOS release as extension-safe, and the core alone with no adapter in
reach. - The history scan forgives the pull request number in the subject GitHub writes on a merge
commit, and nowhere else. The scanner and the check runner are scanned line by line, with only
their exact fixture lines allowed.
v0.3.1
Fixes the four package findings from the 0.3.0 audit, and documents the host-integration order the same audit found an adopter getting wrong. No API change, so this is a patch: a conformance that builds against 0.3.0 builds against this unchanged.
Closes #17.
Delivery
- A reset no longer loses the drain for a signal recorded during it. The drain slot is given up inside the erase's own lock section, and only the task captured there is cancelled; a record landing immediately after schedules its own.
- A zero-delay request no longer skips the retry a failure owes.
flush()and a full batch both ask for one, and both happen on the consumer's cadence rather than the endpoint's, so the retry deadline is now carried apart from the coalescing delay and a zero-delay request takes the later of the two. A drain halted for want of an identifier owes the same wait.
Queue file
- A file that cannot be read is told from one that cannot be decoded. Only the second is purged. A read failure leaves the file where it is and suspends the writes over it; a read that succeeds later lifts the suspension; an erase outranks both.
- Absence is asked of the read rather than of an existence check, which answers "no" for a directory the process cannot reach as readily as for a container that holds no queue.
- A purge that can neither delete nor overwrite the file leaves a mark beside it, and no later load restores a queue under a standing mark. The mark is settled by the delete that finally lands, and an outstanding one is completed before a fresh queue is written.
- Reads, writes and erases take the file's own lock. The refusals a store carries are its own, so one store owns its file: two live stores over one path, in one process or two, is not supported and now says so.
Prose
- ADOPTING.md gains a data-reset phase: keep the collection gate shut across the whole reset rather than only the erase, read the consent answer back at the end rather than capturing it at the start, reopen the counted session after, and serialize the host's own consent check with its own identifier resolution — the package cannot un-mint an identifier a host persisted before anything reached the recorder.
- SECURITY.md says what erasing guarantees against a filesystem that refuses it, rather than promising deletion no package can make.
- STABILITY.md states the read-versus-decode split and the mark, and that neither is API.
132 tests, up from 122.
0.3.0
Fixes every finding from the 0.2.1 audit. One API change, which is why this is 0.3.0 and not 0.2.2.
Breaking
SignalgainssessionID, stamped at record time.SignalBatchloses it. A customSignalTransportthat readbatch.sessionIDreadssignal.sessionIDnow.- A 0.2.x queue file is unreadable to 0.3.0 and is purged on first load; the signals in it are lost. The retention record is unaffected.
Consent and erasure
- A batch built before a
reset()or a decline cannot go out, and an outcome for it cannot touch what replaced it. - The identifier provider, the retention save and clear, and the purge all run under the recorder's lock; an erase waits for the purge to reach disk before returning.
- Erasure clears the session id and the cached environment.
- The adapter no longer writes a debug envelope file.
RetentionStoreandSignalQueueStorageconformances must not call back into the recorder; the doc comments say so.
Queue
- A purge whose delete is refused overwrites the file in place.
- Acknowledged batches are removed by counting front evictions, so an overflow during a send no longer resends.
- A non-finite measure is coerced to nil by
Signal, on construction and on decode. - A signal recorded during the last batch of a drain is delivered without waiting for the next record.
- A nil identifier backs off instead of waking every interval.
- Intervals must be finite and positive; a backoff cap below the interval floors at the interval.
- Corrupt retention counts saturate instead of trapping.
Prose and gates
- STABILITY.md lists the whole public surface and states the 0.3.0 move.
- Install snippets pin
exact:. - The package's own leak scan reads the index, refuses numbered issue URLs, and scans history in CI.
Payload version stays 2.0.0.
v0.2.1
A one-value correction to what 0.2.0 stamped on every signal. No API change,
no behaviour change.
What was wrong
0.2.0 set the payload version sdk.version to 1.1.0. The minor position says
the payload shape grew and nothing a reader depends on went away. The opposite
happened: four run-context fields were removed and one replaced them.
Telling a reader which shape a row arrived in is the only thing that field
does, so stamping it in the position that promises no removal defeats it.
sdk.version is now 2.0.0.
Rows already sent
Signals sent by 0.2.0 carry 1.1.0 and describe the same shape as 2.0.0.
They cannot be restamped. A reader separating shapes should treat 1.1.0 and
2.0.0 as one, and treat 1.0.0 as the older shape with the four run-context
booleans.
That window is one release wide, which is the reason to correct this now rather
than after more of it accumulates.
The rule this came from
STABILITY.md said when the payload version moves and never which position it
moves in, which is how a minor bump looked defensible for a removal. It now
names the reader as who the version promises to and reads the positions off
that: a removal is major, an addition is minor, and a changed form is whichever
of the two a reader would have to react to.
Upgrading
Nothing in the API list moved, which is why this sits in the patch position.
Anyone on 0.2.0 upgrades by changing the version and rebuilding. Anyone coming
from 0.1.x should read the 0.2.0 notes first: that release removed four
PayloadKey constants and changed EnvironmentSnapshot.init.
99 tests in 12 suites.
v0.2.0
The release 0.1.1 should have been. It carries what 0.1.1 carried, plus two
fixes found afterwards, in the version position the stability contract asks
for.
0.1.1 is not retracted or moved. It stays published, and anything pinned
exactly to it keeps resolving.
Read this before upgrading
Source-breaking. Four PayloadKey constants are gone:
runContextIsDebugrunContextIsSimulatorrunContextIsTestFlightrunContextIsAppStore
They are replaced by runContextChannel, carrying dev, beta or store.
EnvironmentSnapshot.init changed with them: it takes channel: instead of
the four booleans. testPartition(for:) asks whether the channel is .store.
The adapter still emits the vendor's RunContext.isTestFlight wire name on
every channel, not only beta, so dashboards built before the collapse keep
resolving. A chart filtering on isTestFlight == false reads an absent key as
missing rather than false, which is why the flag rides even where it reads
false.
The from: "0.1.0" hazard. from: is upToNextMajor, so it resolves to
>=0.1.0 <1.0.0. Anyone pinned that way picks this release up on their next
resolve and hits the removed keys and the changed initializer at build time.
This is the reason the release sits in the minor position rather than the
patch one, and the reason 0.1.1 was mis-numbered: it made the same break in a
position that promises no break. STABILITY.md now states which position a
0.x release moves.
The ingest URL
ingestURL handed appending(path:) a path already ending in a separator
together with directoryHint: .isDirectory, so the result depended on which
Foundation resolved it: v2// on 6.2, v2/ on 6.3. The vendor 404s the
doubled form and the adapter drops a 404 as permanent.
The path is now assembled through URLComponents, writing each separator
exactly once, so the URL is identical on every Foundation. Every trailing
separator on the base is stripped rather than one, so a base ending in //
cannot leave a doubled separator behind — the gap 0.1.1 shipped with.
Two properties come with that construction: work happens on the percent-encoded
path, so an already-encoded base segment survives verbatim, and the namespace
is encoded as a single component, so a slash inside it stays data instead of
inventing a path segment the server routes on.
The payload version
sdk.version is 1.1.0. It answers which payload shape a row arrived in, and
the shape changed when the run-context fields collapsed. Rows sent by 0.1.1
are stamped 1.0.0 despite carrying the new shape; rows from this release
onward are stamped correctly.
99 tests in 12 suites.