The package format
A design system that cannot be carried from one toolchain to another is not portable, and a design system whose accessibility claims cannot be checked on arrival is not trustworthy. Version 1.0.0 defines a single file that answers both: one ZIP container, two known files at its root, and a digest for every byte it carries.
The container, and what identifies it
A package is a ZIP archive with the extension .afds. Nothing about that is clever, and that is the point. A ZIP reader is one of the few things I can rely on already being present wherever a package needs to be opened, so a format built on one adds no dependency to the person receiving it.
The container rules exist because ZIP syntax permits a great deal that a portable interchange format should not. The specification therefore constrains it.
| Rule | Requirement |
|---|---|
| Root files | afds-manifest.json and afds-inventory.json MUST both exist at the archive root. |
| No wrapper directory | A single enclosing top-level directory MUST NOT wrap the contents, so a consumer knows where the root is without guessing. |
| Paths | Normalised relative paths using / as the separator. No absolute paths, no .. or . segments, no drive letter, no UNC prefix. |
| Encoding | Text entries are UTF-8 with no byte order mark. |
| Encryption | No entry is encrypted. A portable interchange file that cannot be opened is not interchange. |
| Directory entries | Permitted, because many ZIP writers emit them, but they carry no meaning and MUST NOT appear in the inventory. |
Then there is the identification problem. A package has the media type application/zip, because that is what it is and there is no registered type for AFDS. The IANA registration for application/zip is unusually candid about the limitation this creates. It says the type “is not recommended for normal use, since Content-Type information for the files contained within the archive are not known.”
That sentence describes exactly the gap AFDS has to close. A ZIP media type tells a consumer that bytes are compressed and nothing at all about what they mean. So identification does not rest on the media type. A consumer identifies a package by finding a parseable afds-manifest.json at the archive root whose afdsFormat field is the exact string afds-package. The extension is a convenience for humans and file managers; the manifest is the actual test.
A consumer MUST NOT rely on application/afds+zip. That type is not registered, and treating an unregistered type as though it were is how a format acquires an identity it has no right to claim. Whether to seek registration is recorded as an open question rather than assumed.
What the package holds
The hierarchy is declared rather than discovered. Nine directories are defined, all of them optional except as a declared conformance profile requires them, and a consumer knows what each one means before opening it.
example.afds
afds-manifest.json REQUIRED identifies and describes the package
afds-inventory.json REQUIRED one digest record per other entry
LICENSES.md OPTIONAL the text of both licences
tokens/ DTCG canonical token sources
components/ contracts and specifications
patterns/ multi-component specifications
manifests/ Custom Elements Manifest output
evidence/ AT records, Reflow, limitations
adapters/ declarations, outputs, reports
docs/ human-readable documentation
schemas/ JSON Schema for the artefacts
stories/ Component Story Format storiesWhat lives in each directory is described at length in portable representations, which is where the choice of DTCG for tokens, Custom Elements Manifest for component surfaces and Component Story Format for stories is argued. This page is about the envelope rather than the letter.
One thing does need saying here, because it is easy to misread the tree above as a repository layout. It is not. The design-system repository is the original source, and a package is the finished work built from it. That is why the two do not share a root manifest name. The repository carries a design-system.manifest.json that indexes sources under active edit, and a package carries an afds-manifest.json that describes a built artefact sealed against a matching inventory. A file whose contents are still moving cannot be the same kind of object as one whose digests must hold.
The two are deliberately different objects with different economics: a repository is good at line-by-line collaboration and bad at being handed to someone as one verifiable thing, and a package is the reverse. Whether the project should build tooling that lets a person edit a package directly, rather than unpacking it, editing, rebuilding the inventory and repacking, is unresolved and appears below among the open questions.
Roles, and the rule that keeps a package honest
Every inventoried entry declares exactly one role. There are six: canonical, derived, adapter, evidence, documentation and schema.
Roles look like bookkeeping and are not. They exist to support one rule, which is the rule I think does more work than anything else in the format.
A derived or adapter artefact MUST NOT be the only source of a fact owned by a canonical artefact.
Two consequences follow. Every derived artefact MUST be regenerable from the canonical artefacts in the same package alone, with no access to anything outside it. And a consumer MAY discard every derived and adapter entry and still hold a complete design system.
That second consequence is a test a person can actually run. Delete the generated CSS, the Figma output and every adapter product, then ask whether the remaining files still say what the buttons guarantee, which relationships are required and which assistive-technology combinations have been tested. If the answer is no, a fact has leaked into a derived artefact and the package is broken in a way no digest will catch.
The failure this prevents is one I keep meeting in real systems: the only place a behaviour is written down is the generated output, or the design-tool file, or a comment in the adapter. When the generator changes, the fact is gone, and nobody notices because nothing declared it in the first place. Adapters, and the reasons no adapter is ever canonical, have their own page.
A related rule governs prose. Documentation MUST NOT introduce a normative fact, and where documentation and a machine-readable contract disagree, the contract wins. This is not a slight against prose, which does the teaching. It is an answer to the question of what a reader should do when the two conflict, and answering it in advance is cheaper than arbitrating it later.
The manifest
The manifest identifies the package and declares what it contains. Its required fields cover format and version, packageId and packageVersion, title and description, creation date, the declared conformance profile, both licences, the publisher, the DTCG version and canonical token sources, and the adapters.
{
"afdsFormat": "afds-package",
"afdsVersion": "1.0.0",
"packageId": "com.a11ybob.abd.afds-sample",
"packageVersion": "1.0.0",
"title": "AFDS Sample",
"created": "2026-08-29",
"conformanceProfile": "afds-components",
"licences": {
"code": "GPL-3.0-only",
"documentation": "CC-BY-SA-4.0"
},
"publisher": {
"name": "Bob Dodd",
"project": "Accessible by Design",
"uri": "https://a11ybob.com/"
},
"tokens": {
"dtcgVersion": "2025.10",
"canonicalSources": [
{
"id": "core",
"path": "tokens/core.tokens.json",
"role": "canonical"
}
]
},
"adapters": [],
"stories": []
}The example is abridged from the sample package that ships in the project repository, alongside the full field table in the specification. The component, pattern, evidence, schema and documentation declarations have the same shape as the token block and are omitted here for length.
Notice "adapters": [] and "stories": []. The empty array is required rather than optional, and that is a deliberate choice with a reason worth stating. An empty array is a positive declaration that the package ships no adapters and no stories. An omitted field is silence, and silence is ambiguous between “none” and “forgot”. A reviewer can challenge a claim. A reviewer cannot challenge an absence.
The same reasoning runs through the format wherever a collection might be empty, and it is the same reasoning behind treating uncertainty as a record type rather than a gap, which is argued in evidence and uncertainty.
Two licence fields appear rather than one because the project licenses code and documentation differently, and a single licence field would force one of the two to be wrong. Both carry SPDX identifiers so that a consumer can resolve them mechanically.
The inventory and its digests
The inventory holds exactly one record for every entry in the archive, except directory entries, which carry no meaning, and except itself. Each record gives the path, the media type, the exact uncompressed byte length, the role, and the SHA-256 digest of the entry’s exact uncompressed bytes as lowercase hexadecimal.
{
"afdsFormat": "afds-inventory",
"afdsVersion": "1.0.0",
"packageId": "com.a11ybob.abd.afds-sample",
"packageVersion": "1.0.0",
"digestAlgorithm": "SHA-256",
"digestEncoding": "lowercase-hex",
"excludesSelf": true,
"entryCount": 9,
"records": [
{
"path": "afds-manifest.json",
"mediaType": "application/json",
"byteLength": 2767,
"role": "canonical",
"sha256": "b480866e44ae0d66 ... 654d38475cf51748"
},
{
"path": "tokens/core.tokens.json",
"mediaType": "application/json",
"byteLength": 3055,
"role": "canonical",
"sha256": "b45bb732e28f4c3f ... 753b29dc68c7a29b"
}
]
}The digests above are elided in the middle for readability. In a real inventory a sha256 value MUST be the full sixty-four lowercase hexadecimal characters, and a consumer MUST reject a truncated, uppercase or base-64 digest rather than trying to interpret it. Guessing at a digest format defeats the purpose of having one.
The exclusion of the inventory from its own records is not an oversight, and the excludesSelf field exists so that a consumer can see it was intended. A self-record could never hold a correct digest: writing the digest into the file changes the file, which changes the digest. There is no fixed point to reach. So the inventory covers everything else and says so explicitly, rather than leaving a reader to wonder whether a record is missing.
SHA-256 comes from the Secure Hash Standard, which specifies seven algorithms and states plainly what they are for: “The digests are used to detect whether messages have been changed since the digests were generated.” Detecting change is the whole of what the inventory does, and the section on integrity below is about the things it therefore cannot do.
Records SHOULD be sorted by path in ascending byte order. That is purely a review convenience, and an honest one to admit as such: sorted records mean a rebuilt inventory produces a diff showing only genuine changes, instead of a reordering that hides them.
Verifying a package before trusting it
A conforming consumer verifies the inventory before relying on any package content. Before parsing a token file. Before reading a component contract. Before extracting anything to disk. The procedure has ten steps and their order matters.
- Open the file using ZIP syntax. If it is not a readable ZIP archive, report a container failure and stop.
- Check every entry path: normalised, relative, no
..or.segment, no leading/, no drive letter or UNC prefix, and no single enclosing top-level directory. Report every violation and stop. Do not sanitise. - Confirm no entry is encrypted, and apply the configured decompression limits. Report every violation and stop.
- Locate
afds-manifest.jsonat the root, decode it as UTF-8, parse it, confirmafdsFormat, then readafdsVersionand apply the version rules. - Locate
afds-inventory.jsonat the root and confirm its format, digest algorithm, digest encoding andexcludesSelfflag, and that itspackageIdandpackageVersionmatch the manifest. - Confirm completeness in both directions: every archive entry other than directories and the inventory itself has exactly one record, and every record names an entry that exists. Confirm
entryCountmatches. Report every unmatched name on both sides. - Compare every byte length. Report every mismatch.
- Recompute every digest and compare it. Report every mismatch. If any digest fails, the consumer MUST NOT rely on any package content.
- Validate each canonical token source against the DTCG version the manifest declares. A consumer that cannot validate against the declared version MUST report that it did not validate, rather than passing the step silently.
- Emit one report: a pass or fail verdict, the count of entries checked, and every individual problem found.
Two properties of that ordering are deliberate rather than incidental.
Steps two and three run before anything is parsed or written, so a hostile archive is rejected before its content is touched. Steps six through nine gather every problem rather than stopping at the first, because a report that stops at the first defect makes a producer fix one thing per round trip, and a producer who has to rebuild eleven times to find eleven faults has every incentive to stop verifying at all.
The last step carries a requirement that sounds obvious and which I have watched enough tools break to want it written down explicitly. A consumer MUST NOT report a pass when any step failed, and MUST distinguish “checked and passed” from “not checked”. A validator that reports silence as success is worse than no validator, because it manufactures confidence.
A package is untrusted input
A package arrives from somewhere else. That single fact determines how a consumer must treat it, and the two attacks it has to survive are both long-documented weaknesses of ZIP extraction rather than anything novel.
The first is path traversal. ZIP syntax stores a path per entry and does nothing to stop that path being absolute or containing ..segments, so an extractor that joins the entry path onto an output directory can be made to write outside it and overwrite arbitrary files. This is catalogued as CWE-22, “Improper Limitation of a Pathname to a Restricted Directory”, whose observed examples include a Go archive library that “allows extraction of files to locations outside of the target folder” through traversal sequences in zip filenames, “aka Zip Slip”. The IANA registration warns of it too, in its own security considerations: “Extracting a zipfile could possible overwrite existing files.”
So a consumer MUST reject any entry whose path is absolute, contains a .. or . segment, or is not normalised, and MUST do so before extracting anything. It MUST NOT rewrite an offending path into a safe one. Silent repair is tempting and wrong: it changes what the package says and hides the fact that something tried to escape.
The second is data amplification. A small archive can expand to an enormous volume, and nesting archives multiplies it. CWE-409 names this “Improper Handling of Highly Compressed Data (Data Amplification)” and gives the canonical example: “a small ZIP file that can produce a large amount of data when it is decompressed.” A consumer MUST enforce configured limits and MUST fail rather than continue when one is reached.
| Limit | Purpose | Suggested default |
|---|---|---|
| Entry count | Bound records and file handles | 5000 entries |
| Total compressed size | Bound the input read | 32 MiB |
| Total uncompressed size | Bound memory and disk | 256 MiB |
| Decompression ratio | Detect one compressible entry | 200 to 1 |
| Nesting depth | Bound recursion and nesting | 16 path segments |
| Path length | Bound filesystem interaction | 255 characters |
Those numbers are suggestions, not requirements, and the requirement attached to them matters more than the values do. A consumer MUST make its limits configurable and MUST report which limit was exceeded. The reason is practical: when a legitimately large package fails, the fix should be raising one named limit, not switching the checks off. A validator whose only failure message is that something was too big leaves a person no route forward except disabling it.
One further ordering rule. A consumer SHOULD compute the uncompressed total from the archive’s own metadata first and reject an over-large package before decompressing anything, then enforce the same limit again during decompression, because the declared metadata may lie.
Integrity is not authenticity
Inventory integrity is not a digital signature. I would rather state that in a heading than in a footnote, because I have watched a file full of SHA-256 digests be read as proof of where something came from, and it is nothing of the kind.
What the digests do is genuinely useful. They detect that content changed between the moment the inventory was written and the moment it was verified, which catches truncated downloads, corrupted media, accidental edits and careless repackaging. Those are the failures that actually happen to files in transit, and catching them is worth the cost of the inventory on its own.
| Property | Provided? |
|---|---|
| Detects accidental or in-transit change | Yes |
| Detects change made after the inventory was written | Yes |
| Identifies who produced it | No |
| Proves it came from the claimed publisher | No |
| Stops an attacker rewriting content and rebuilding the inventory | No |
| Establishes a chain of custody | No |
The reason for the four negatives is a single sentence. An attacker who can alter the content can also recompute the digests and rewrite the inventory. Nothing in the package binds it to a key, so nothing in it can be attributed to anyone. The publisher object in the manifest is a claim, not evidence, and a consumer MUST NOT present it as more than that.
Until a signature mechanism exists, trust in a package MUST come from the channel it arrived on rather than from the package itself. What that mechanism should be is an open question below, and I would rather carry an acknowledged gap than a reassuring claim I cannot support.
Profiles are a floor, not a description
A profile lets a package say how complete it is, so that a consumer can reject a package lacking what it needs without inspecting the whole hierarchy. The manifest carries exactly one profile identifier.
| Profile | Identifier | Requires |
|---|---|---|
| Tokens only | afds-tokens | Root manifest and inventory, and at least one declared canonical token file. |
| Components | afds-components | All of the above, plus at least one component with both a machine-readable contract and a human-readable specification. |
| Full | afds-full | All of the above, plus canonical evidence records, a known-limitations artefact, and a declared test fixture for every component. |
A package MUST satisfy every requirement of the profile it declares, and MAY exceed it. The consequence of the second half is the part I expect to be argued with: a consumer MUST treat the declared profile as a floor rather than a description, and a consumer needing a higher profile MUST refuse the package even when inspection shows the extra artefacts are present. An undeclared artefact carries no commitment to still be there in the next version, so relying on one is relying on an accident.
The afds-full profile requires evidence records but does not require that they contain results. A record whose result is not-yet-tested conforms. That is deliberate, and it is the design decision in this section I would defend hardest. Writing down an untested combination is the mechanism by which uncertainty becomes visible at all, and a profile that demanded results would create pressure to invent them. The choice is between a package that admits it has not tested Dragon with Chrome and a package that quietly claims it has.
Versioning
Two versions travel in every package and they move independently. afdsVersion is the version of the package format. packageVersion is the version of the design-system payload. Both use semantic versioning.
Keeping them separate is what lets a design system publish a hundred payload releases against one stable format, and lets the format evolve without implying that every published system changed.
For the format, adding an optional field, an optional directory or a new profile is minor. Adding a required field, removing a field, changing a type or changing the meaning of an existing field is major. Correcting prose without altering a requirement is a patch.
What a consumer does when the format version is not the one it knows is specified rather than left to implementers, because that is where quiet incompatibilities breed.
| Situation | Required behaviour |
|---|---|
| Major matches, minor known | Process normally. |
| Major matches, minor higher | MUST process, MUST ignore unrecognised fields, SHOULD report reading a newer minor version. |
| Major matches, minor lower | MUST process, and MUST NOT require a field introduced in a later minor version. |
| Major higher than supported | MUST refuse, MUST report the unsupported version, MUST NOT attempt a partial read. |
| Major lower than supported | MAY refuse, or MAY process in a documented compatibility mode. MUST state which it did. |
| Absent or unparseable | MUST treat the package as non-conforming. |
The asymmetry between a higher and a lower major is the interesting row. A higher major may rely on semantics the consumer cannot know about, so guessing risks silently misreading an accessibility contract, which is the worst available outcome. A lower major is fully knowable, so a compatibility mode is safe provided it is declared.
Payload versioning runs on the same logic applied to the design system rather than the format. Removing a component, removing a token, renaming an identifier or narrowing a guarantee is major. Adding a component, a token or evidence is minor. Correcting prose is a patch.
One rule there does not follow from ordinary semantic versioning and is worth stating on its own. Withdrawing an assistive-technology guarantee is a major payload change even when nothing else moves, because a consumer may have relied on it. The mirror case is minor: adding an evidence record that turns a recorded uncertainty into a guarantee takes nothing away. Semantic versioning usually tracks the shape of an interface. Here it has to track what was promised about behaviour, and a promise can be withdrawn without a single identifier changing.
Why not OPC
Open Packaging Conventions is the obvious prior art, and declining to use it needs an argument rather than a preference. It is a formal ZIP-based multi-part container, standardised as ECMA-376 Part 2, whose fifth edition is dated December 2021, and as ISO/IEC 29500-2:2021. The ISO abstract describes it as defining “a set of conventions for packaging one or more interrelated byte streams (parts) as a single resource (package)”, and notes the conventions are applicable “not only to Office Open XML specifications ... but also to other markup specifications”. Office Open XML is built on it, and so is OpenXPS, whose own standard says its “format requirements are an extension of the packaging requirements described in the Open Packaging Conventions (OPC) Standard.”
Its machinery is worth describing accurately, because the rejection is a rejection of specific mechanisms rather than of the idea. ECMA’s own overview sets them out. Content types are declared in a [Content_Types].xmlitem, which “allows a consumer to determine the content type of every part in the package”. Relationships live in separate relationship parts, where “the relationships part for the package as a whole is called /_rels/.rels”. And discovery works by walking those relationships: “to open a package, an application must parse the package-relationships part and follow the relationships of appropriate type.”
AFDS borrows the principle and rejects the machinery.
| OPC mechanism | Position | Reason |
|---|---|---|
| One logical object of related parts | Adopted | This is the principle worth keeping, and OPC shows a ZIP archive is a sound basis for it. |
| XML parts as the content model | Rejected | AFDS content is JSON and Markdown centred. Wrapping JSON in XML parts adds a representation nobody needs. |
[Content_Types].xml | Rejected | The inventory already carries a media type per entry, in the same file that carries its digest. |
_rels relationship parts | Rejected | The manifest already supplies the relationship map, in one place, in the format the rest of the package uses. |
| Part-naming grammar | Rejected | Normalised relative ZIP paths are sufficient and are what ordinary tools already show. |
| Discovery by relationships | Rejected | A consumer reads two known root files. Convention is simpler and easier to verify. |
The cost of that rejection is real and I would rather state it than let a reader discover it. AFDS gains nothing from the existing body of OPC tooling, and a developer who already knows OPC has to learn a second set of conventions to no benefit. The judgement is that XML parts and a relationship model add complexity without improving a JSON and Markdown centred representation, and that a manifest a person can read in a text editor is worth more to this project than reuse of an XML relationship library. Someone building on a stack that already speaks OPC could reasonably weigh that differently.
What is still open
Four questions are unresolved, and recording them is better than settling them by assumption.
Media-type registration. A dedicated registration such as application/afds+zip would give the format a stable identity in HTTP and in operating-system type databases. Whether a project draft should seek registration before its field names are stable is the question, and I lean towards waiting.
Signing. A signature mechanism is needed before a package can be trusted on the strength of its own contents. Which format to adopt, what exactly is signed, where the signature lives given that the inventory cannot record itself, and how keys are distributed for a project with no registry are all open.
Delta distribution. A package is a whole-file artefact, so correcting one component ships as a complete replacement. For a large system with frequent evidence updates that is wasteful and it obscures what actually changed. Whether to define a delta package, and how one would interact with inventory verification and versioning, is unresolved.
Package-aware editing.Editing one artefact currently means unpacking, editing, rebuilding the inventory and repacking. Whether to build tooling that removes that friction, or to keep treating the repository as the working format and the package purely as a distribution artefact, is unresolved. These and the rest of the project’s live questions are collected in open questions.
None of this is a standard. AFDS 1.0.0 is a project draft, on no standards track, and the package format is the part of it most likely to change if anyone else ever implements a consumer.
References
- IETF. RFC 2119. Best Current Practice 14. The requirement keywords used throughout, and the warning that such imperatives “must be used with care and sparingly”. rfc/rfc2119
- IANA. Media type application/zip. The registration, its note that content types within an archive are not known, and its overwrite security consideration. application/zip
- NIST. FIPS 180-4, Secure Hash Standard. The seven algorithms specified, and the statement that digests detect change. NIST.FIPS.180-4.pdf
- MITRE. CWE-22, Path Traversal. The weakness description and the Zip Slip examples quoted above. definitions/22
- MITRE. CWE-409, Improper Handling of Highly Compressed Data (Data Amplification). The decompression-bomb example quoted above. definitions/409
- Ecma International. ECMA-376. Part 2, Open Packaging Conventions, 5th edition, December 2021. standards/ecma-376
- Ecma International. Office Open XML Overview. The source of the quoted descriptions of parts,
[Content_Types].xml,_relsand relationship-walking discovery. OpenXML-White-Paper.pdf - Ecma International. ECMA-388, Open XML Paper Specification. 1st edition, June 2009. The source of the quoted sentence on extending OPC packaging requirements. ECMA-388_1st_edition.pdf
- ISO. ISO/IEC 29500-2:2021. Edition 4, August 2021. The abstract quoted above. standard/77818
- Design Tokens Community Group. Design Tokens Format Module, 2025.10. The version declared by
tokens.dtcgVersion. tr/2025.10/format - Bob Dodd. Accessible by Design. The package format document, the sample package and its inventory builder. accessible-by-design