Specifications

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

TermMeaning
Bundled fallbackPublished bundles committed into an app's repo and shipped with the app
SeedLoading bundled fallback files at SDK startup
Seed directoryThe folder holding the bundled fallback files: airstrings/bundles/
CandidateA 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
  1. The seed directory is airstrings/bundles/ relative to the platform root (see Platform packaging).
  2. Each file is named {locale}.json, where {locale} is exactly the bundle's locale field.
  3. File names are discovery hints only. SDKs MUST match bundles by the verified locale field inside the file.
  4. One seed directory holds bundles for exactly one environment. Multi-environment setups use one directory per build flavor.
  5. 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_id inside 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" }
  ]
}
FieldTypeDescription
manifest_versionintegerCurrently 1
generated_atstringRFC 3339 UTC timestamp of the last pull that changed the directory
cli_versionstringCLI version of that pull
project_id / env_id / env_namestringProvenance of the pull
org_idstringOptional; included when derivable from the bundle URL
bundles[]arrayOne entry per file: locale, file, revision, created_at
  1. The manifest is unsigned and advisory. SDKs MUST NOT base any security or anti-downgrade decision on it.
  2. SDKs MAY use it to enumerate available locales.
  3. SDKs MUST tolerate a missing, malformed, or stale manifest by enumerating *.json files (excluding manifest.json) or probing {locale}.json directly.
  4. 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:

  1. Parse and structurally validate the envelope.
  2. key_id must match a configured public key.
  3. The Ed25519 signature must verify over the canonical signed content.
  4. format_version must be recognized.

After signature verification, SDKs MUST also reject the seed if:

  1. project_id differs from the SDK's configured project ID.
  2. locale differs 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:

  1. 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.
  2. The candidate with the highest revision is applied. On a tie, the cached candidate wins (same revision means same content).
  3. If the seed wins, the SDK writes it to the cache, so later cold starts do not depend on the seed.
  4. 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.
  5. 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.

PlatformRootSeed locationZero-config
Web (Node, SSG, SSR)process.cwd()<cwd>/airstrings/bundles/ read from diskYes
Web (browser)Build outputBundle JSON imported at build time and passed via the seed optionOne-line config
iOSBundle.mainairstrings/bundles/ added to the app target as a folder referenceYes
Androidassets/assets/airstrings/bundles/Yes
Gofs.FS (default: working directory)./airstrings/bundles/ read from disk, or an embed.FS passed via SeedYes
  • Web (Node): with no explicit seed configured, the SDK probes <cwd>/airstrings/bundles/. Filesystem access is conditional so browser builds never reference fs. 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 subdirectory airstrings/bundles. The hierarchy MUST survive the build: Xcode targets add the folder as a folder reference (not a group); SPM targets declare resources: [.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/ via AssetManager. Copy the folder into src/main/assets/ or map it with a sourceSets.assets.srcDirs entry.
  • Go: with no explicit seed configured, the SDK probes ./airstrings/bundles/ relative to the working directory. Seed accepts any fs.FS holding {locale}.json files, typically an embed.FS of the committed folder; DisableSeed turns 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]
  1. 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.
  2. 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.
  3. CLI-side verification. Before writing, the CLI verifies each bundle's Ed25519 signature against its embedded key_id and cross-checks project_id, locale, and revision against 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.
  4. Mirror semantics. The CLI writes current bundles, updates the manifest when contents change, and deletes {locale}.json files whose locale is no longer published. Files outside the managed pattern are never touched. Two runs with no upstream changes produce zero diff.
  5. Atomicity. Files are staged and moved into place only after every bundle is fetched and verified. A failed pull leaves the previous state intact.
  6. CI-friendly. Non-interactive, deterministic ordering, non-zero exit on failure. --json emits { "dir", "env_id", "bundles": [{ "locale", "revision", "file" }] }.
  7. --locale restricts the pull to one locale.
  8. 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_id is 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 pull in 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:

  1. Offline cold start. No cache, no network, valid seed: bundled strings served, ready state reached, no errors.
  2. Tampered seed. Modified strings (invalid signature): rejected, never served or cached, error surfaced; key-name fallback continues.
  3. Downgrade ignored. Cache at revision N, seed at N−1: cached strings served, cache not overwritten.
  4. Seed newer than cache. Cache at N, seed at N+1: seed served and persisted to cache.
  5. Missing seed. No seed directory: behavior unchanged, no errors or warnings.
  6. Wrong project or locale. A validly signed bundle for another project_id, or a ja bundle in en-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.

On this page