Cryptographic witness attestation for AI inference on Android, iOS, and embedded devices. Write-ahead log for intermittent connectivity, device context attestation, and clearing level optimization for bandwidth-constrained environments.
EU AI Act Article 6 applies to edge devices. On-device inference is not exempt from transparency and record-keeping obligations. Mobile AI systems making decisions that affect individuals require the same evidence chain as cloud-hosted models. A healthcare triage app on a tablet, a credit scoring model on a banking app, or an agricultural advisory system on a field device -- all fall within scope if they affect persons in the EU.
Who this is for: Mobile AI developers deploying on-device inference (TFLite, ONNX, MediaPipe, Core ML), Android/Kotlin and iOS/Swift developers, edge computing teams managing device fleets, and compliance teams responsible for AI systems running on mobile or embedded hardware.
Mobile AI is not a special case. It faces the same regulatory obligations as cloud AI. EU AI Act Article 6 covers AI systems regardless of deployment location. The regulation applies to the system, not the infrastructure.
Healthcare triage apps on Android tablets. Agricultural advisory models on field devices. Financial scoring on mobile banking apps. Insurance claim assessment on adjuster phones. All of these make decisions that affect individuals, and all require evidence of what the model decided, when, and on what device.
The difference is the infrastructure. Mobile environments impose constraints that cloud deployments do not face:
SWT3 addresses these constraints with offline-first architecture: hash locally, buffer locally, flush when connected. The witness payload is small (200 bytes to 2KB depending on clearing level), the write-ahead log survives process death, and the flush strategy adapts to network conditions.
Key principle: Raw inference data never leaves the device. Only cryptographic hashes and metadata are transmitted. This satisfies data minimization requirements (GDPR Article 5(1)(c)) while maintaining a complete evidence chain for the AI system's behavior.
The Kotlin SDK provides the same witness primitives as the Python and TypeScript SDKs, with Android-specific optimizations for lifecycle management, storage, and network awareness.
// build.gradle.kts
dependencies {
implementation("io.tenova:swt3-ai:0.6.2")
}
import io.tenova.swt3.WitnessClient
import io.tenova.swt3.WitnessConfig
val witness = WitnessClient(WitnessConfig(
endpoint = "https://sovereign.tenova.io",
apiKey = "axm_live_...",
tenantId = "YOUR_TENANT_ID",
clearingLevel = 2, // Bandwidth-friendly default
))
val result = witness.wrap(
prompt = userInput,
response = modelOutput,
modelId = "tflite-classifier-v3",
provider = "on-device",
)
// result.fingerprint is a 12-char hex anchor
// WAL persists to local storage automatically
The wrap() call hashes the prompt and response locally using SHA-256, generates a fingerprint using the locked SWT3 formula, and appends the witness payload to the local write-ahead log. When the device has connectivity, buffered payloads flush to the server automatically.
Every wrap() call generates an AI-INF.1 witness anchor. The fingerprint formula is identical across all 9 SDK languages, ensuring cross-platform verification.
Assessor Tip
Request the device's WAL directory listing to verify that witness payloads accumulate during offline periods. Compare WAL entry count against server-side anchor count after a flush cycle to confirm no evidence gaps.
Every mobile witness can include device context via DeploymentContext. This proves where and on what hardware the inference ran. An assessor can verify that a healthcare triage model ran on a certified device class, not on a desktop emulator or rooted phone.
import io.tenova.swt3.DeploymentContext
val context = DeploymentContext(
deviceModel = android.os.Build.MODEL,
osVersion = "Android ${android.os.Build.VERSION.RELEASE}",
chipType = android.os.Build.HARDWARE,
runtimeVersion = "Kotlin ${KotlinVersion.CURRENT}",
)
val result = witness.wrap(
prompt = userInput,
response = modelOutput,
modelId = "tflite-classifier-v3",
provider = "on-device",
deploymentContext = context,
)
The deployment context is included in the witness payload at clearing levels 0 and 1. At clearing level 2, only the device model is retained. At clearing level 3, device context is omitted entirely.
The write-ahead log (WAL) persists witness payloads to $TMPDIR/swt3-wal/{tenantId}.wal as JSONL (one JSON object per line). On Android, this maps to the app's internal storage directory (context.filesDir), which is private to the application and survives across app updates.
Storage budget: At clearing level 2 (~400 bytes per entry), a 5MB WAL holds approximately 12,500 witness payloads. At one inference per second, that covers over 3 hours of continuous offline operation before rotation begins.
// Default WAL path on Android
// /data/data/com.yourapp/files/swt3-wal/{tenantId}.wal
//
// Access via:
val walDir = File(context.filesDir, "swt3-wal")
// WAL files are plain JSONL -- one witness payload per line
Default buffer settings are optimized for always-connected servers. Mobile environments need different tuning to balance evidence freshness against battery life and network efficiency.
| Parameter | Server Default | Mobile Recommended | Rationale |
|---|---|---|---|
| Flush interval | 30 seconds | 300 seconds | Reduce network wake-ups, save battery |
| Batch size | 10 payloads | 50 payloads | Fewer, larger uploads reduce TCP overhead |
| Dead-letter limit | 1,000 payloads | 5,000 payloads | Mobile accumulates more during offline periods |
| Retry backoff | Exponential, max 60s | Exponential, max 600s | Avoid draining battery on repeated failures |
Check connectivity before draining the buffer. On metered connections, defer the flush unless the WAL is approaching rotation.
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
fun shouldFlush(cm: ConnectivityManager, walSizeBytes: Long): Boolean {
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
// Always flush on WiFi
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return true
// On cellular, only flush if WAL is above 80% of rotation threshold
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
return walSizeBytes > 4_000_000L // 80% of 5MB
}
return false
}
The WAL ensures zero evidence gaps even during extended offline periods. Every inference is logged locally before the model response is returned to the caller. The flush strategy determines latency to the server, not completeness of the record.
Clearing levels control how much metadata is included in each witness payload. On bandwidth-constrained mobile networks, the right clearing level can reduce data transfer by 10x.
| Level | Name | Payload Size | Included | Mobile Use Case |
|---|---|---|---|---|
| CL0 | Analytics | ~2 KB | Full metadata, model ID, device context, all hashes, observations | WiFi-connected devices, development |
| CL1 | Standard | ~1.5 KB | Model ID, reduced context, factor hashes | Connected tablets, kiosk devices |
| CL2 | Sensitive | ~400 bytes | Model ID only, no context details | Field devices, cellular connections |
| CL3 | Classified | ~200 bytes | Factors only, model ID hashed | Air-gapped devices, high-security mobile |
Recommendation: Use CL2 as the default on mobile (400 bytes vs 2 KB = 5x bandwidth reduction). Upgrade to CL0 when on WiFi for richer evidence. The fingerprint is identical at all clearing levels -- only the surrounding metadata changes.
// Dynamic clearing level based on network type
val clearingLevel = when {
isOnWifi(connectivityManager) -> 0 // Full metadata on WiFi
isOnCellular(connectivityManager) -> 2 // Minimal on cellular
else -> 3 // Offline or unknown -- factors only
}
A device performing 1,000 inferences per day at CL2 transmits approximately 400 KB per flush. At CL0, the same volume would require 2 MB. Over a fleet of 10,000 devices, that difference is 4 GB vs 20 GB per day.
Assessor Tip
Verify that the clearing level configuration matches the data classification of the AI system. A healthcare triage model should operate at CL2 or CL3 by default. If CL0 is used, confirm that the additional metadata does not contain protected health information.
On-device pipelines often chain multiple models. An image classifier feeds into a text extractor, which feeds into a sentiment analyzer. Each model in the chain makes a decision that should be witnessed independently, but the chain itself should be traceable as a single unit of work.
Use auto-chaining to link all witnesses in a pipeline with a shared cycle_id:
// All witnesses in this block share the same cycle_id
witness.chain("field-inspection-pipeline") { ctx ->
val classResult = witness.wrap(
prompt = imageInput,
response = classOutput,
modelId = "image-classifier-v2",
)
val textResult = witness.wrap(
prompt = classOutput,
response = extractedText,
modelId = "text-extractor-v1",
)
val sentResult = witness.wrap(
prompt = extractedText,
response = sentiment,
modelId = "sentiment-v2",
)
// ctx.cycleId links all three anchors
// Forensic reconstruction can replay the entire pipeline
}
The chain label ("field-inspection-pipeline") becomes a queryable tag in the forensic timeline. An assessor can reconstruct the full decision path for any individual inference by querying the cycle ID.
Auto-chaining creates a linked sequence of witness anchors sharing a single cycle ID. Each anchor in the chain is independently verifiable, but the chain itself proves the order and completeness of the pipeline execution.
SWT3 maintains cross-platform parity for mobile edge attestation. The fingerprint formula is identical across all SDK languages, verified against shared test vectors.
| Capability | Android (Kotlin) | iOS (Swift) |
|---|---|---|
| Fingerprint formula | Identical -- cross-language parity verified | Identical -- cross-language parity verified |
| Write-ahead log | App internal storage (JSONL) | On-device buffer (JSONL) |
| Core ML witness | Not applicable | witnessPrediction() -- Swift only |
| TFLite witness | wrap() pattern | Not applicable |
| ONNX Runtime | Supported via wrap() | Supported via wrap() |
| MediaPipe | Supported via wrap() | Supported via wrap() |
| Payload signing | HMAC-SHA256 | HMAC-SHA256 + Secure Enclave P-256 |
| DeploymentContext | Full parity | Full parity |
| Auto-chaining | Supported | Supported |
| Clearing levels | 0, 1, 2, 3 | 0, 1, 2, 3 |
For Apple-specific details including Core ML prediction witnessing, Secure Enclave signing, and spatial provenance for visionOS, see the Edge Attestation Guide.
Cross-platform verification: An anchor generated by the Kotlin SDK on a Pixel 8 can be verified by the Swift SDK on an iPhone, or by the Python SDK on a server. The fingerprint formula is locked and produces identical output across all 9 SDK languages for the same input.
The following SWT3 procedures are directly relevant to mobile edge AI deployments. Each procedure maps to specific regulatory obligations that apply regardless of whether the AI system runs in the cloud or on a device.
| Procedure | Name | Mobile Relevance |
|---|---|---|
AI-INF.1 | Inference Provenance | Every on-device prediction generates a witness anchor proving what the model decided and when |
AI-MDL.1 | Model Integrity | Verify the model file has not been tampered with on the device (hash comparison against known-good checksum) |
AI-HW.1 | Hardware Attestation | Device context proves the inference ran on specific hardware, not an emulator or unauthorized device class |
AI-LOG.1 | Logging Completeness | WAL ensures no gap in the evidence record, even during extended offline periods |
AI-COST.1 | Resource Consumption | On-device compute time, battery impact, memory usage -- resource governance for edge inference |
AI-GRD.1 | Guardrail Activation | On-device content filters, safety classifiers, and output validation running before the response reaches the user |
AI-HITL.1 | Human Review | Human override of mobile AI decisions, review latency, reviewer identity binding |
Article 9 (risk management) requires documented evidence of how risks are identified and mitigated -- AI-INF.1 + AI-GRD.1 provide this. Article 12 (record-keeping) requires automatic logging of AI system operations -- AI-LOG.1 + the WAL satisfy this. Article 14 (human oversight) requires mechanisms for human intervention -- AI-HITL.1 attests to override events.
For model-specific compliance patterns including quantization witnessing (AI-MDL.7), distillation provenance, and the RAG+SLM evidence chain, see the SLM Compliance Evidence Guide.
| Question | Procedure |
|---|---|
| How do I prove what my on-device model decided? | AI-INF.1 -- wrap every inference |
| How do I verify the model file was not tampered with? | AI-MDL.1 -- hash the model binary on load |
| How do I prove which device ran the inference? | AI-HW.1 -- include DeploymentContext |
| How do I handle offline periods without evidence gaps? | AI-LOG.1 -- WAL persists locally, flushes on reconnect |
| How do I track battery and compute cost of AI features? | AI-COST.1 -- resource consumption witnessing |
| How do I attest that content filters ran before output? | AI-GRD.1 -- guardrail activation witness |
| How do I prove a human reviewed the AI decision? | AI-HITL.1 -- human review witness with reviewer binding |
| How do I trace a multi-model pipeline as one unit? | AI-CHAIN.1 -- auto-chaining with shared cycle ID |
Complete Kotlin example showing configuration, device context, single inference witness, chained pipeline, and flush.
import io.tenova.swt3.WitnessClient
import io.tenova.swt3.WitnessConfig
import io.tenova.swt3.DeploymentContext
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
class InferenceWitness(private val appContext: Context) {
private val connectivityManager =
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val witness = WitnessClient(WitnessConfig(
endpoint = "https://sovereign.tenova.io",
apiKey = "axm_live_...",
tenantId = "YOUR_TENANT_ID",
clearingLevel = dynamicClearingLevel(),
flushInterval = 300_000L, // 5 minutes
batchSize = 50,
))
private fun dynamicClearingLevel(): Int {
val network = connectivityManager.activeNetwork ?: return 3
val caps = connectivityManager.getNetworkCapabilities(network) ?: return 3
return when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 2
else -> 3
}
}
private fun deviceContext() = DeploymentContext(
deviceModel = android.os.Build.MODEL,
osVersion = "Android ${android.os.Build.VERSION.RELEASE}",
chipType = android.os.Build.HARDWARE,
runtimeVersion = "Kotlin ${KotlinVersion.CURRENT}",
)
// Single inference witness
fun witnessInference(prompt: String, response: String, modelId: String): String {
val result = witness.wrap(
prompt = prompt,
response = response,
modelId = modelId,
provider = "on-device",
deploymentContext = deviceContext(),
)
return result.fingerprint // 12-char hex anchor
}
// Chained pipeline witness
fun witnessPipeline(imageBytes: ByteArray) {
witness.chain("field-inspection") { ctx ->
val classification = runClassifier(imageBytes)
witness.wrap(
prompt = imageBytes.toString(),
response = classification,
modelId = "image-classifier-v2",
)
val extractedText = runOcr(classification)
witness.wrap(
prompt = classification,
response = extractedText,
modelId = "text-extractor-v1",
)
val assessment = runAssessment(extractedText)
witness.wrap(
prompt = extractedText,
response = assessment,
modelId = "assessment-v3",
)
// All three anchors share ctx.cycleId
}
}
// Call when app moves to background or on WiFi connect
fun flushIfReady() {
val network = connectivityManager.activeNetwork ?: return
val caps = connectivityManager.getNetworkCapabilities(network) ?: return
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
witness.flush()
}
}
}
Full SDK documentation: sovereign.tenova.io/docs
Create a free account: sovereign.tenova.io/signup