Skip to content

Releases: splitleafhq/aethergram

v2.1.0

Choose a tag to compare

@seriouslysean seriouslysean released this 05 Oct 16:30
adaae70

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.version is 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 on sdk.version sees 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 than exact:, so a host copying one takes
    every 2.x release. Pinning exact: is still open to a host that wants each release on its own
    schedule.

Tooling

  • Scripts/check-version-stamp.sh refuses a tree whose Aethergram.version is not the version
    in this file's top heading, and runs in Scripts/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

Choose a tag to compare

@seriouslysean seriouslysean released this 29 Sep 22:38
a4664c2

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 as TelemetryDeck.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 bare systemVersion and
    systemMajorMinorVersion, and the new field takes the same form as those two.
  • runContext.language, sent as TelemetryDeck.RunContext.language: the language the app runs in,
    the locale's language code.
  • calendar.dayOfWeek, sent as TelemetryDeck.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.language is the language the user most prefers on the device, the language
    subtag of the first Locale.preferredLanguages entry, as the vendor defines it. It was the
    locale's language, which is the app's, so an app localized only in English reported en from a
    device set to German. The key and the form are unchanged.
  • calendar.isWeekend is true on 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 reported true and a Sunday false. 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() returns true when 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 returns false on 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 later beginSession()
    reports it: a host that counts a data reset as a new install records the preset once that call
    returns. A RetentionStore whose load() returns nil, for a record it could not decode
    included, reads as no record, and the next session reports true.
  • SignalRecorder.recordNewInstallDetected(parameters:) records the preset, unprefixed like the
    other presets. The recorder never records it on its own: a host calls it when beginSession()
    returns true, on the path every other signal takes, so whatever the host applies there covers
    it too.
  • EnvironmentSnapshot holds the OS version once, as systemMajorVersion, systemMinorVersion,
    and systemPatchVersion, in place of systemVersion and systemMajorMinorVersion, and derives
    all three version fields from it. language becomes preferredLanguage and appLanguage. The
    initializer takes the new properties.
  • EnvironmentSnapshot.current() takes preferredLanguages:, defaulting to
    Locale.preferredLanguages.

Upgrading

  • A call to EnvironmentSnapshot's initializer passes the three version components and the two
    languages. Calls to current() and beginSession() 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 first beginSession() 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 at 58f43629 and its default-parameters reference at 2b9c2108, 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

Choose a tag to compare

@seriouslysean seriouslysean released this 25 Sep 17:39
698100d

Closes #43 and #46.

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, which TelemetryDeckTransport reads 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 which flushAndWait() 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/checkout v7.0.1.

v0.4.1

Choose a tag to compare

@seriouslysean seriouslysean released this 24 Sep 12:52
8384efc

Closes #24.

A patch: nothing in the API list moved, and the payload version stays 2.0.0.

Privacy manifest

  • PrivacyInfo.xcprivacy moved 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
    recordPurchaseCompleted or recordError declares 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

Choose a tag to compare

@seriouslysean seriouslysean released this 24 Sep 03:11
361ca68

Closes #21. Deferred to #22.

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:) is reset() 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, where reset() 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, and reset()
    erases and leaves collection closed.
  • watchOS 11 is declared, with iOS 18 and macOS 15.
  • The core's bundle, Aethergram_AethergramCore.bundle, ships PrivacyInfo.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.send is spelled nonisolated(nonsending) in the protocol rather than left to
    the module's feature flag. The requirement means what it meant, and a plain async witness
    still conforms.

TelemetryDeck adapter

  • TelemetryDeckTransport sends over an ephemeral session with no cookie, credential, or URL cache
    store by default, where 0.3.2 used URLSession.shared, so nothing the host's shared session
    holds reaches the ingest. A session passed to session: is used as given.
  • TelemetryDeckConfiguration traps at construction on a baseURL whose scheme is not https.
    0.3.2 accepted any scheme. A development server over http has to move to https.

Delivery

  • A retry after a failure is owed at a random draw between the larger of transmitInterval and
    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 default queueLimit, 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 run flushAndWait() inside the platform's
    expiring-time API, ProcessInfo.performExpiringActivity in an extension or beginBackgroundTask
    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 with beginSession(). 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 dev channel, which lands in the same
    test partition as beta.

Tooling

  • Scripts/run-checks.sh adds 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.sh on every push of a tag matching v*, refusing a tag
    this file's top heading does not name with a date.

v0.3.2

Choose a tag to compare

@seriouslysean seriouslysean released this 23 Sep 18:49
d1fb8a8

Closes #19.

A patch: nothing in the API list moved, and the payload version stays 2.0.0.

Configuration and traps

  • AethergramConfiguration clamps transmitInterval and maxBackoffInterval to 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 whenever batchSize is above 1, or a backoff sleep — and one past about 1.7e20
    seconds, where Duration.seconds itself traps, crashed wherever it was first made a Duration.
    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. queueLimit is not
    capped, and one that is not positive still traps there too.
  • TelemetryDeckTransport fails a precondition naming the background URLSession at the first
    send over one. The send aborted the host at the same moment before, with an NSGenericException
    about completion handlers that did not name the transport.
  • Calling back into the recorder from a seam it calls under its lock (clientUserProvider,
    environmentProvider, any RetentionStore call, 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 newest queueLimit signals, 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.firstSessionDate and the distinct-day history) are yyyy-MM-dd in 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 as 2018-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 as 0042-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 a record advances 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 that runContext.channel is 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 .appex has to sit two
    directory levels under the .app, as in PlugIns/ or Extensions/; any other layout keeps the
    extension's own.
  • A calendar.hourOfDay value 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

Choose a tag to compare

@seriouslysean seriouslysean released this 12 Sep 17:13
711f3f4

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

Choose a tag to compare

@seriouslysean seriouslysean released this 10 Sep 18:17
e3fd46d

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

  • Signal gains sessionID, stamped at record time. SignalBatch loses it. A custom SignalTransport that read batch.sessionID reads signal.sessionID now.
  • 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.
  • RetentionStore and SignalQueueStorage conformances 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

Choose a tag to compare

@seriouslysean seriouslysean released this 04 Sep 13:31
e4f4ec4

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

Choose a tag to compare

@seriouslysean seriouslysean released this 04 Sep 13:27
f83080e

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:

  • runContextIsDebug
  • runContextIsSimulator
  • runContextIsTestFlight
  • runContextIsAppStore

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.