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.tomlflags/*.tomlsegments/*.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/orsegments/whose names do not end in.toml. - Subdirectories under
flags/andsegments/(the linter ignores them; shipping them serves no purpose). - Auxiliary files at the namespace root other than
namespace.toml. - Anything matched by a
.exdignoreglob 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:
| Limit | Default | Enforcement point |
|---|---|---|
| Per-file size | 256 KB | Linter (E019) and server during extraction. |
| Total uncompressed archive size | 50 MB | Server during extraction. Rejects with archive_too_large (HTTP 413). |
| Total compressed archive size on the wire | 5 MB | Server 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:
- Receiving. Reject with
archive_too_large(413) if the archive exceeds the compressed-on-wire (5 MB) or uncompressed (50 MB) limits. - 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
E018in the lint report. - 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. - Schema-version range check. Reject with
schema_version_mismatch(422) if any file's major version is outside the server's supported range. - Authorization. Verify the principal has
manifest.write. See Access Control. - Optimistic concurrency. Honor the
If-Versionheader. - 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:
- Pulls the current manifest. The response carries the current
manifest_version(e.g.,7). - Edits local files.
- Pushes with header
If-Version: 7. The server accepts the upload only if the current server-side version is still7.
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 value | Effect |
|---|---|
<N> (integer ≥ 1) | Accept upload iff current version is exactly <N>. |
0 | Accept upload iff the flag namespace has no prior manifest (manifest_version = null). |
| Header omitted | Force-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.gzis 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.
Recommended practice
- Always run
exd lintlocally beforeexd manifest push. The server runs the same linter, but failing fast saves a round-trip. - Always pass
--if-versionin 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
exdCLI 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
exd manifest push— the reference packaging + upload command.exd manifest pull— round-trip the other way.exd manifest versions— inspect the history.- Server API — the full HTTP contract.
- Access Control — who can push what.
- Schema versioning — the version-compatibility model behind
schema_version_mismatch. - diagnostics § E018 — the symlink rejection rule.
- diagnostics § E019 — the 256 KB per-file cap.