Skip to main content

Packaging and upload

A manifest is uploaded to the exd server as a single archive. This page is the reference for the archive format, what it contains and excludes, size limits, and the optimistic-concurrency model used to prevent races.

The HTTP transport details (endpoints, headers, response codes) live in Server API. This page is the manifest-side contract: what an archive contains and how a flag namespace round-trips through pull and push.


Archive format

The manifest is packaged as a gzip-compressed tar archive (.tar.gz), conventionally named <namespace-slug>-v<version>.tar.gz (e.g., payments-v8.tar.gz).

What the archive contains

The archive contains the contents of the flag-namespace directory, not the directory itself. When extracted, it expands to:

namespace.toml
flags/
<flag-key>.toml
...
segments/
<segment-key>.toml
...

— not into a <namespace-slug>/ wrapper. The reference packaging pattern:

cd payments
tar -czf ../payments.tar.gz .

Or without cd:

tar -czf payments.tar.gz -C payments/ .

What goes in

Every regular file inside the flag-namespace directory that the linter would otherwise validate:

  • namespace.toml
  • flags/*.toml
  • segments/*.toml

What is excluded

The reference packaging tool (exd manifest push) excludes:

  • Hidden files (names beginning with .) at any path: .git, .DS_Store, .idea, .exdignore.
  • Files in flags/ or segments/ whose names do not end in .toml.
  • Subdirectories under flags/ and segments/ (the linter ignores them; shipping them serves no purpose).
  • Auxiliary files at the namespace root other than namespace.toml.
  • Anything matched by a .exdignore glob pattern.

These exclusions mirror what the linter ignores at validation time. Round-tripping through pull then push MUST be idempotent: any file silently ignored by the linter is also excluded from the archive.

.exdignore

.exdignore is an optional file at the flag-namespace root. It contains gitignore-style glob patterns (one per line; # introduces a comment) relative to the root. Matched patterns are excluded from the archive. The .exdignore file itself is also excluded.

# Local notes and experiments
NOTES.md
scratch/
*.bak

# IDE state
.vscode/

.exdignore does NOT affect the linter's traversal — the linter ignores root-level files other than namespace.toml regardless. It exists to give authors an explicit, reviewable list of what does and does not ship to the server.


Archive size limits

The server enforces three independent size caps:

LimitDefaultEnforcement point
Per-file size256 KBLinter (E019) and server during extraction.
Total uncompressed archive size50 MBServer during extraction. Rejects with archive_too_large (HTTP 413).
Total compressed archive size on the wire5 MBServer before extraction. Rejects with archive_too_large (HTTP 413). Protects against zip-bomb DoS.

A single flag-namespace's manifest is typically a few kilobytes per flag plus a few kilobytes per segment; reaching the uncompressed cap requires hundreds of thousands of files or unusually large variant payloads. The compressed cap is hit first in practice.


Upload pipeline

Manifests are uploaded via PUT /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest. The body is multipart/form-data with a single field named archive containing the .tar.gz file.

The server validates each upload by:

  1. Receiving. Reject with archive_too_large (413) if the archive exceeds the compressed-on-wire (5 MB) or uncompressed (50 MB) limits.
  2. Tar safety. Reject any entry that is a symbolic link, hard link, device file, or whose extracted path escapes the namespace root (path traversal). Symlinks surface as E018 in the lint report.
  3. Extract and lint. Run the same lint pipeline the CLI uses. Any error-severity diagnostic → reject with manifest_lint_failed (422) including the full lint report.
  4. Schema-version range check. Reject with schema_version_mismatch (422) if any file's major version is outside the server's supported range.
  5. Authorization. Verify the principal has manifest.write. See Access Control.
  6. Optimistic concurrency. Honor the If-Version header.
  7. Atomic apply. Increment the manifest version, store the archive immutably, invalidate the in-memory manifest cache. The new manifest is visible to evaluations atomically.

If any step fails, the current manifest is unchanged.


Optimistic concurrency: If-Version

The If-Version header on the manifest PUT is the format's concurrency control mechanism. It prevents two writers from racing on the same flag namespace.

How it works

A client that intends to update a manifest:

  1. Pulls the current manifest. The response carries the current manifest_version (e.g., 7).
  2. Edits local files.
  3. Pushes with header If-Version: 7. The server accepts the upload only if the current server-side version is still 7.

If another writer pushed between the pull and push, the server's version is now 8. The push with If-Version: 7 is rejected with 409 Conflict (error code version_conflict). The client must pull the new version, re-apply its changes, and retry.

Header values

If-Version valueEffect
<N> (integer ≥ 1)Accept upload iff current version is exactly <N>.
0Accept upload iff the flag namespace has no prior manifest (manifest_version = null).
Header omittedForce-push: replace the current manifest unconditionally.

Pattern: agent / CI retry loop

1. exd manifest pull payments --format json # → version: 14
2. (edit flags/checkout-redesign.toml)
3. exd lint ./payments
4. exd manifest push acme/payments ./payments --if-version 14
├─ accepted → version is now 15, done
└─ rejected → version_conflict; goto 1 with the latest version

Agents and CI jobs that retry on conflict eliminate the race without locking.


Round-trip guarantees

The server stores manifests immutably. Every successful upload creates a new version with a monotonically increasing integer; old versions are never deleted unless the flag namespace itself is deleted.

GET /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest returns the current manifest as a .tar.gz.

GET /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest/versions/{version} returns a specific historical version.

A pulled archive that is uploaded again without modification:

  • Produces a new version with a new version number (the server does NOT deduplicate identical content).
  • Preserves the contents of every file byte-for-byte.
  • Is not required to be byte-identical at the archive level. The server normalizes tar entry order (lexicographic), strips per-file mtimes and ownership, and uses gzip's default compression level; the resulting .tar.gz is reproducible across runs but may differ from the originally uploaded archive's framing bytes.

Lint behavior is deterministic: two consecutive lints of an unchanged directory MUST produce the same diagnostics (bucket-segment hashing is deterministic by definition).


File deletions

To delete a flag or segment, remove the corresponding .toml file from the manifest and re-upload. The next manifest version will not contain the entity; SDKs polling the manifest will stop returning evaluations for that key (server returns flag_not_found / segment_not_found).

There is no soft-delete or tombstone. Deletion is via removal from the next manifest. Historical versions (still accessible by version number) retain the file as it existed in version n.


  • Always run exd lint locally before exd manifest push. The server runs the same linter, but failing fast saves a round-trip.
  • Always pass --if-version in agent and CI workflows. Force pushes are sometimes legitimate (rollback, recovery) but should be the exception.
  • Tag manifest archives with the version number when storing them externally (payments-v8.tar.gz). The version is what audit trails and rollbacks reference.
  • Treat lint warnings as errors in CI. Use --deny-warnings (or a hand-rolled check on the JSON report). Warnings flag risky patterns; promoting them to blocking checks catches problems earlier.
  • Pin the exd CLI version in CI. A newer CLI may parse a manifest the production server rejects (or vice versa) for major-version reasons; pinning prevents drift.

See also