Step 2 scaffold with one vertical slice. This is not an SDK yet.
There is no transport, no persistence, no bridge, no release, no package on any registry, and no public support of any distribution channel is promised. Nothing here has been committed, pushed or published. The pinned contract bundle is an SPFN primitives export, and the round trip has been proved against a primitives dev server on localhost — never against a deployed service. Read docs/SCAFFOLD-STATUS.md before assuming any capability exists.
SPFN Mobile is the polyglot repository for the SPFN iOS (Swift) and Android (Kotlin) native SDKs and the optional hybrid WebView adapter. Both platforms share one release train and one contract import, so the security model, proof canonicalization and error model cannot drift apart between them.
| Area | State |
|---|---|
| Swift module graph | 6 targets, building in Swift 6 language mode |
| Android module graph | 6 modules, compiling as AAR libraries |
| Contract | one bundle pinned by real SHA-256 — exported by SPFN primitives, copied here at an exact commit |
| Codegen | tools/contract-codegen produces both clients from that bundle, deterministically |
clientProofV1 |
canonical proof input, SHA-256 digest, ECDSA P-256 proof over a registered public key, replay and revocation rules |
| Canonical JSON | SPFN-CANON-JSON-1, implemented independently on both platforms |
| Conformance | one fixture directory, two suites, cross-platform digest parity |
| Offline validator | working, and still the cheapest gate in the repository |
| CI | three required checks on every pull request — swift, android, contract — and three manual-only files for what D2 leaves outside |
| Release / publication | disabled by default, and no channel is configured |
Package.swift SwiftPM manifest (repo root, required by SwiftPM)
settings.gradle.kts Android multi-project root
Contracts/ pinned contract bundle, lock, schemas, conformance fixtures
Sources/ Swift targets: SPFNCore, SPFNGenerated, SPFNAuth,
SPFNClient
Tests/ Swift unit, repository and conformance tests
android/ Kotlin modules: spfn-core, spfn-generated, spfn-auth,
spfn-client
tools/ module graph, offline validator, contract codegen,
CocoaPods fixture
examples/ reference apps (placeholders)
docs/ architecture, migration, security, open decisions
Swift Package Manager is the only iOS distribution channel (D11, resolved). Android
modules are Gradle/Maven modules. CocoaPods is not supported; tools/cocoapods-compat/
holds an internal, unpublished fixture whose only job is to prove the module graph stays
single-sourced.
Android consumers must build with Kotlin 2.4 or later: the published AARs carry
Kotlin 2.4 metadata, which the Kotlin 2.2.x compiler bundled by an AGP 9 default
toolchain cannot read. See the Android row in COMPATIBILITY.md.
sh tools/validate/validate.sh # offline structural validation
swift build && swift test # Swift targets and conformance
./gradlew build # Android modules and conformance
./gradlew :contract-codegen:spfnCodegenVerify # generated sources are up to date
./gradlew spfnToolchainReport # the effective D5 baseline
pod ipc spec tools/cocoapods-compat/generated/SPFNMobileCompatFixture.podspecThe Gradle commands need an Android SDK; point ANDROID_HOME at it. Each command is
separate evidence, and the validator never infers or reports the result of the others.
Three workflows gate a pull request, and the checks to mark required are swift,
android and contract — branch protection is a repository setting, so no file here
can turn them on.
Each one calls a script under tools/ci/ and does nothing else, because a workflow file
cannot be run on a developer machine and a gate nobody can reproduce locally is a gate
nobody can fix:
sh tools/ci/validate.sh # the offline validator, judged against tools/ci/validate-known-red.txt
sh tools/ci/android.sh # unit tests, lint, codegen verification
sh tools/ci/swift.sh # swift build --build-tests, swift test --skip-buildtools/ci/validate.sh is the one that needs explaining: the validator exits non-zero
whenever it counts any failure, and the device-receipt gate has been red on purpose since
the 2026-09-02 contract re-pin. So the script judges the validator's output against a
named list of admitted failures instead of obeying its exit code — and a failure that
stops happening reddens the check too, so the list cannot outlive its reason. See
tools/ci/README.md.
What CI does not run is what D2 leaves outside it: the macOS half of the Swift suite, any
emulator or real device, the integration run (its swift-e cell is Mac-only), and
anything signed or published. security.yml, release-candidate.yml and
publish-central.yml stay manual for that reason.
A device with no key on file shows a short code; somebody approves it on a device that is already signed in. Both halves are below, and both are the whole of what an app writes.
The waiting device — one call, which returns when somebody answers:
let signedIn = try await lifecycle.enrollByDeviceCode(deviceName: "Kitchen iPad")
{ userCode, expiresAtMillis in show(userCode, until: expiresAtMillis) }val signedIn = lifecycle.enrollByDeviceCode(deviceName = "Kitchen tablet")
{ userCode, expiresAtMillis -> show(userCode, expiresAtMillis) }The approver — three generated descriptors through execute, with the key this device
already has. There is no SDK wrapper, the same way there is none for auth.keys.revoke:
let waiting = try await client.execute(SPFNGeneratedCalls.authDeviceInfo, request: SPFNDeviceAuthInfoRequest(userCode: typed))
_ = try await client.execute(SPFNGeneratedCalls.authDeviceApprove, request: SPFNApproveDeviceAuthRequest(userCode: typed))
_ = try await client.execute(SPFNGeneratedCalls.authDeviceDeny, request: SPFNDenyDeviceAuthRequest(userCode: typed))val waiting = client.execute(SpfnGeneratedCalls.authDeviceInfo, SpfnDeviceAuthInfoRequest(typed))
client.execute(SpfnGeneratedCalls.authDeviceApprove, SpfnApproveDeviceAuthRequest(typed))
client.execute(SpfnGeneratedCalls.authDeviceDeny, SpfnDenyDeviceAuthRequest(typed))SPFNGeneratedCalls / SpfnGeneratedCalls carries one descriptor per contract operation,
named exactly as the operation is. Nothing is written by hand.
What the app must show is the userCode exactly as it arrived — XXXX-XXXX, unmodified,
since the server folds case, spaces and dashes when it is typed back in — and that it
expires: the callback's second argument is the instant it stops working, and the call ends
by itself at that instant with the SDK's device-code expiry error. Only that expiry and a
real refusal end the wait — a request the network lost, whichever of the wait's two it was,
costs the interval and is asked again — so an app has no retry of its own to write. The
callback runs on
whatever executor the caller was on; the SDK switches to none of its own, so an app that
draws from it hops to its own main thread. The approver should be shown what info
answers — the device name and the fingerprint prefix — before approving, because that is
the whole defence against approving somebody else's device.
The approved repository and release topology is the decision artifact
2026-07-27-spfn-mobile-sdk-repository-topology-decision.html. Everything still open is
listed in docs/OPEN-DECISIONS.md — including the Maven
namespace, ownership and signing. D17 is resolved: the contract is exported by SPFN
primitives and pinned here at an exact commit.