Bundled Fallback
Specification for shipping published, signed bundles inside an app: seed directory layout, manifest, verification, anti-downgrade rules, platform packaging, and airstrings bundles pull.
This is the specification for shipping published, signed bundles inside an application so SDKs can serve strings with no network. The CLI produces the folder; every SDK seeds from it on startup. For a task-oriented introduction, see Offline and Fallback.
Problem
SDKs cache bundles after the first successful fetch, but a cold start with no network (SSG/SSR builds, first launch offline, CI) has nothing to serve and degrades to key names. Bundled fallback solves this without giving up any signing guarantee.
Vocabulary
| Term | Meaning |
|---|---|
| Bundled fallback | Published bundles committed into an app's repo and shipped with the app |
| Seed | Loading bundled fallback files at SDK startup |
| Seed directory | The folder holding the bundled fallback files: airstrings/bundles/ |
| Candidate | A verified bundle competing to become the active bundle for a locale (cached, seeded, or fetched) |
Artifact
Files in the seed directory are the exact published artifacts the CDN serves, byte for byte, including signature, key_id, revision, and every envelope field defined in the Bundle Format. There is no separate offline format: a bundled fallback file is a bundle.
Folder layout
airstrings/bundles/
manifest.json # written by the CLI, advisory only
en-US.json # published bundle, byte-identical to the CDN artifact
ja.json
pt-BR.json- The seed directory is
airstrings/bundles/relative to the platform root (see Platform packaging). - Each file is named
{locale}.json, where{locale}is exactly the bundle'slocalefield. - File names are discovery hints only. SDKs MUST match bundles by the verified
localefield inside the file. - One seed directory holds bundles for exactly one environment. Multi-environment setups use one directory per build flavor.
- The directory is meant to be committed. It MUST never contain credentials, API keys, or private key material; the only key material is the public
key_idinside each bundle.
Manifest
The CLI maintains manifest.json in the seed directory. It records provenance the bundle envelope cannot, notably the environment, which is not part of the signed content.
{
"manifest_version": 1,
"generated_at": "2026-06-10T12:00:00Z",
"cli_version": "0.15.0",
"org_id": "org_a1b2c3d4e5f6",
"project_id": "proj_a1b2c3d4e5f6",
"env_id": "env_x1y2z3w4v5u6",
"env_name": "production",
"bundles": [
{ "locale": "en-US", "file": "en-US.json", "revision": 42, "created_at": "2026-06-09T18:00:00Z" }
]
}| Field | Type | Description |
|---|---|---|
manifest_version | integer | Currently 1 |
generated_at | string | RFC 3339 UTC timestamp of the last pull that changed the directory |
cli_version | string | CLI version of that pull |
project_id / env_id / env_name | string | Provenance of the pull |
org_id | string | Optional; included when derivable from the bundle URL |
bundles[] | array | One entry per file: locale, file, revision, created_at |
- The manifest is unsigned and advisory. SDKs MUST NOT base any security or anti-downgrade decision on it.
- SDKs MAY use it to enumerate available locales.
- SDKs MUST tolerate a missing, malformed, or stale manifest by enumerating
*.jsonfiles (excludingmanifest.json) or probing{locale}.jsondirectly. - The CLI rewrites the manifest only when a pull changes the directory contents. A no-change pull leaves it byte-identical. Hand edits are not supported.
Verification
A seed file is untrusted input: anyone with commit access can modify it. SDKs MUST run the full bundle verification procedure on every seed load, exactly as for a network bundle:
- Parse and structurally validate the envelope.
key_idmust match a configured public key.- The Ed25519 signature must verify over the canonical signed content.
format_versionmust be recognized.
After signature verification, SDKs MUST also reject the seed if:
project_iddiffers from the SDK's configured project ID.localediffers from the locale the file was loaded for.
Rejection is a hard error: a failing seed file is never applied, never cached, and never silently ignored. The SDK reports it on its error channel (the same one used for network verification failures), then continues startup as if the file did not exist. The SDK never crashes and never blocks app startup.
A missing seed directory, missing locale file, or empty directory is not an error: the SDK falls back silently to cache, then network, then key names. Silence is reserved for absence; corruption is always loud.
Revision semantics and anti-downgrade
Seeding extends the "highest revision wins" rule to a three-way race between cache, seed, and network:
- On startup and on locale change, the SDK gathers the cached bundle and the seed bundle for the locale. Each is verified independently; an invalid cache entry is deleted, an invalid seed is a hard error.
- The candidate with the highest
revisionis applied. On a tie, the cached candidate wins (same revision means same content). - If the seed wins, the SDK writes it to the cache, so later cold starts do not depend on the seed.
- A seed MUST never overwrite a candidate with a higher revision, in memory or in cache. An app update that ships an older seed than the device's cache is a no-op.
- The network refresh then runs in the background, subject to the same anti-downgrade check.
Platform packaging
The relative path airstrings/bundles/ is identical everywhere; only the root differs.
| Platform | Root | Seed location | Zero-config |
|---|---|---|---|
| Web (Node, SSG, SSR) | process.cwd() | <cwd>/airstrings/bundles/ read from disk | Yes |
| Web (browser) | Build output | Bundle JSON imported at build time and passed via the seed option | One-line config |
| iOS | Bundle.main | airstrings/bundles/ added to the app target as a folder reference | Yes |
| Android | assets/ | assets/airstrings/bundles/ | Yes |
| Go | fs.FS (default: working directory) | ./airstrings/bundles/ read from disk, or an embed.FS passed via Seed | Yes |
- Web (Node): with no explicit seed configured, the SDK probes
<cwd>/airstrings/bundles/. Filesystem access is conditional so browser builds never referencefs. An option overrides the directory or disables seeding. - Web (browser): the SDK accepts pre-loaded bundles (parsed objects or raw JSON strings) via
seed, e.g.seed: [enBundle, jaBundle]from build-time imports. Imported content is verified like any other bundle. - iOS: the SDK probes the app bundle (default
Bundle.main, overridable) under the subdirectoryairstrings/bundles. The hierarchy MUST survive the build: Xcode targets add the folder as a folder reference (not a group); SPM targets declareresources: [.copy("airstrings")](never.process, which flattens); Bazel targets use structure-preserving resource rules. A flattened layout is treated as absent. - Android: the SDK probes
assets/airstrings/bundles/viaAssetManager. Copy the folder intosrc/main/assets/or map it with asourceSets.assets.srcDirsentry. - Go: with no explicit seed configured, the SDK probes
./airstrings/bundles/relative to the working directory.Seedaccepts anyfs.FSholding{locale}.jsonfiles, typically anembed.FSof the committed folder;DisableSeedturns seeding off. - All platforms: seeding can be disabled, the location can be overridden, and seeding adds no required configuration.
CLI: airstrings bundles pull
airstrings pull fetches draft strings as editable CSVs. airstrings bundles pull fetches published, signed bundles for shipping. The two never share an output location.
airstrings bundles pull [dir] [--locale <bcp47>] [--json]- Output directory. Defaults to
airstrings/bundles/at the workspace root. A[dir]argument overrides it and is remembered in the workspace config. It MUST NOT be inside.airstrings/, which holds the API key. - Artifact fidelity. The CLI lists the environment's published bundles through the API, then downloads each from its CDN URL. The written file is byte-identical to what SDKs fetch at runtime.
- CLI-side verification. Before writing, the CLI verifies each bundle's Ed25519 signature against its embedded
key_idand cross-checksproject_id,locale, andrevisionagainst the API metadata. A revision mismatch (stale edge) is retried once with a cache-busting query; any remaining failure aborts with a non-zero exit and no partial writes. This is a pipeline integrity check, not the trust anchor: SDKs always re-verify against their own configured keys. - Mirror semantics. The CLI writes current bundles, updates the manifest when contents change, and deletes
{locale}.jsonfiles whose locale is no longer published. Files outside the managed pattern are never touched. Two runs with no upstream changes produce zero diff. - Atomicity. Files are staged and moved into place only after every bundle is fetched and verified. A failed pull leaves the previous state intact.
- CI-friendly. Non-interactive, deterministic ordering, non-zero exit on failure.
--jsonemits{ "dir", "env_id", "bundles": [{ "locale", "revision", "file" }] }. --localerestricts the pull to one locale.- On the first pull into a directory, the CLI prints a short per-platform integration note to stderr.
Security considerations
- Trust anchor. Only the SDK-configured public keys establish trust. The committed folder, the manifest, the CLI's verification, and code review are operational layers, never substitutes for SDK-side verification.
- Environment is not cryptographically bound.
env_idis not part of the signed content. Environment binding comes from the CDN path at fetch time and from pull provenance at seed time, so a validly signed bundle from another environment of the same project and locale would verify. Mitigate operationally: review pulled diffs, pull per environment in CI, keep one seed directory per build flavor. - Staleness on fresh installs. A fresh install serves the committed revision until its first successful fetch. Run
airstrings bundles pullin CI or as a pre-release step. - No secrets. The output contains public keys and localized strings only; the manifest has no credential fields.
Conformance tests
Every SDK passes these, with no network access:
- Offline cold start. No cache, no network, valid seed: bundled strings served, ready state reached, no errors.
- Tampered seed. Modified
strings(invalid signature): rejected, never served or cached, error surfaced; key-name fallback continues. - Downgrade ignored. Cache at revision N, seed at N−1: cached strings served, cache not overwritten.
- Seed newer than cache. Cache at N, seed at N+1: seed served and persisted to cache.
- Missing seed. No seed directory: behavior unchanged, no errors or warnings.
- Wrong project or locale. A validly signed bundle for another
project_id, or ajabundle inen-US.json: rejected with an error, not served.
The CLI additionally tests mirror semantics, manifest idempotency, atomicity on mid-pull failure, byte identity of written files, the remembered output directory, and the --json shape.