3. Document format

A document MUST be a file at the project root named:

  • projectfile.yaml (YAML 1.2 encoding), or
  • projectfile.toml (TOML 1.0 encoding), or
  • projectfile.json (JSON / RFC 8259 encoding).

The file extension MUST match the actual encoding. A project is defined by the convention of its version-control system or packaging system; this specification does not redefine “project”.

YAML is the RECOMMENDED encoding for new projectfile documents. TOML and JSON are accepted encodings for projects whose tooling already speaks one of those formats.

A document MUST be encoded in UTF-8 RFC 3629. A byte order mark (BOM) MUST NOT be present. Line endings MAY be LF or CRLF; consumers MUST accept both.

A document MUST be a syntactically valid file in one of the three accepted serializations:

  • YAML 1.2 [YAML-1.2]. The root MUST be a YAML mapping. Single-document files only; multi-document streams (--- separators) are not documents under this specification.
  • TOML 1.0 TOML-1.0. The root MUST be a TOML table; the document is the root table.
  • JSON RFC 8259. The root MUST be a JSON object.

Note on YAML 1.1 quirks. Some widely deployed YAML libraries default to YAML 1.1 semantics, under which bare yes, no, on, off, y, n, and country code NO are coerced to booleans (the “Norway problem”). Consumers MUST use a YAML 1.2 parser (or a 1.1 parser with boolean coercion disabled). Producers SHOULD quote string values whose unquoted form would be ambiguous.

The three encodings carry the same document. Conversion between them is mechanical: a parser produces an in-memory tree; a serializer renders that tree in the target encoding. Any value expressible in one encoding MUST be expressible in the other two without semantic loss; producers MUST NOT rely on encoding-specific affordances (TOML inline-table syntax, YAML anchors and aliases, comment placement) to convey meaning.

Every document MUST identify itself as a projectfile-v1 document so that consumers can distinguish it from arbitrary TOML/YAML/JSON files that happen to share the filename. The mechanism differs by encoding so that mainstream editors and language servers provide schema-driven validation natively:

  • YAML and JSON. A top-level key $schema whose string value is https://projectfile.org/schema/v1.json. This is the ubiquitous JSON Schema convention; it gives Visual Studio Code, JetBrains IDEs, yaml-language-server, and neovim immediate completion and validation.
  • TOML. A top-level key spec_version whose string value is "1". A first-line magic comment #:schema https://projectfile.org/schema/v1.json (the taplo convention) is RECOMMENDED for editor support; parsers strip comments, so it is not a discriminator and no validator can check it.

A file at one of the projectfile filenames that lacks the appropriate discriminator is not a document under this specification, regardless of name. Consumers MUST NOT interpret such a file as conformant.

The published JSON Schema enforces the structural contract: required fields, types, regular expression patterns on identifiers, and the closed shape of reserved fields. Validators MUST first parse the file according to its encoding and then evaluate the JSON Schema 2020-12 schema against the resulting in-memory tree. The schema cannot tell which encoding a tree came from, so it accepts either discriminator; validators MUST additionally apply the per-encoding rule above, chosen by the file extension. Semantic constraints expressed only in this prose (SHOULDs, RECOMMENDEDs, the cohabitation rules of Section 3.7) are out of scope for the schema and are the responsibility of consuming tools. Where the schema and prose disagree, the prose is authoritative and the schema is to be corrected.

A project root MUST contain at most one projectfile.* file. If two or more are present (e.g., projectfile.toml and projectfile.yaml side-by-side), this is an error:

  • Consumers MUST fail and SHOULD emit a diagnostic naming all the projectfile.* files found.
  • Producers SHOULD NOT write more than one projectfile.* file per project. Tooling that converts between encodings (e.g., the sibling projectfile/sync project) SHOULD remove the source file after conversion or warn loudly that two files now coexist.

This rule exists to make pathological coexistence fail closed: rather than silently picking one file by a side-channel signal (such as modification time), a consumer surfaces the ambiguity so a human resolves it.

Every top-level key in a document belongs to exactly one of two classes:

  • Reserved: $schema and spec_version (by exception, §3.4) and any key matching the pattern ^[a-z][a-z0-9-]*$ that is defined in Section 4. Reserved keys have fixed semantics defined by this specification. Unknown reserved keys (lowercase single-segment keys not defined in the version of Section 4 the consumer was built against) are accepted and preserved (peaceful cohabitation, §3.7).
  • Extension namespace root: any key matching ^[a-z0-9][a-z0-9-]*$ whose value is a mapping representing the first segment of a reverse-DNS path. Each subsequent nested mapping key MUST also match ^[a-z0-9][a-z0-9-]*$. The full path from root to leaf table MUST contain at least 2 segments. See §6 for naming and ownership rules.

The value decides the class: a single-segment key whose value is not a mapping (health: enabled) is an unknown reserved key, never a malformed extension namespace, and is accepted under §3.7. A mapping value always yields a path of at least 2 segments, so the segment-count rule cannot fail at the top level.

A top-level key that matches neither class — for example, Identity (uppercase), foo_bar (underscore), .hidden (leading dot), or a numeric string — is malformed and MUST cause validation to fail.

The dotted-string form, in which an entire reverse-DNS path is packed into a single key (e.g., "dev.acme.tool": as one literal key), is not permitted. All extension namespaces MUST be represented as nested mappings. Rationale: nested mappings round-trip cleanly between TOML, YAML, and JSON; dotted-string keys do not (TOML interprets [a.b.c] as nesting; round-tripping a literal "a.b.c" key through TOML requires escaping that breaks editor tooling).

3.7 The handling rule (peaceful cohabitation)

Section titled “3.7 The handling rule (peaceful cohabitation)”

This specification is designed for peaceful cohabitation: many independently developed tools should be able to read and write the same document without trampling each other, including across spec revisions.

The handling rule has three clauses:

  • A consumer encountering an unknown reserved key (a single-segment lowercase key not defined in the version of Section 4 the consumer was built against) MUST ignore the key for interpretation purposes and MUST preserve it on round-trip. The consumer MUST NOT treat the document as invalid on this account, but MAY emit a warning. Rationale: minor revisions of this specification add reserved fields additively, and a consumer pinned to an older revision must still be able to read newer documents.
  • A consumer encountering an unknown extension namespace (a nested-mapping path the consumer does not recognize) MUST preserve the entire subtree on any round-trip (read-then-write) and MUST NOT treat the document as invalid. Producers MUST NOT remove, rename, or modify nested-mapping subtrees outside their own namespace.
  • A malformed top-level key (Section 3.6) MUST cause validation to fail.

Additionally, the mapping objects defined in Section 4 (identity, license, person, organization, link entries, repositories entries, copyright, requirements) accept additional keys beyond those listed in this specification. Consumers MUST preserve such keys on round-trip and MUST NOT treat the document as invalid for their presence. This allows producers to attach private annotations (e.g. ownership markers like derived) without spec changes, and allows future revisions of this specification to add fields additively.

“Preserve on round-trip” means the values of unknown keys and subtrees are carried through unchanged; encoding-specific formatting details (comment placement, key ordering, indentation style, quoting choices, inline vs. table layout in TOML) MAY change, since most parsers do not retain them. Producers that need to keep formatting intact SHOULD use a comment-aware parser.

The single way for an actor to forbid a key in a consumer is to not implement it. Spec-level rejection is reserved for malformed keys only.

A string value MAY reference another field of the same document. This is what lets a shared includes fragment (§4.9) carry a value that resolves differently in every document that includes it — one fragment, no per-project text.

Syntax. A reference is ${ + a field address + }. The address is a dot-separated sequence of mapping keys, where:

  • a key addressing a list MAY be followed by [N] (zero-based index, negative counts from the end), by [term,…] (the first entry matching every term), or by [] (every entry);
  • a key addressing a mapping of named keys MAY be followed by {term,…} (every entry matching every term).

A term is key=value (the named field equals the value), key (the field is present and not empty) or !key (the field is absent or empty); {kind=package,command} picks every package that exports a command. There is no other comparison, and a bare key list is never a subselect of keys. A key that itself contains dots (an extension namespace written in the flat form) is matched before the path is split, so ${org.projectfile.status} addresses the same value regardless of which encoding of §6.2 the document uses.

A list operator applied to a mapping, or the mapping operator applied to a list, is a malformed address, not an absent value: the two containers are addressed differently and a consumer SHOULD say so rather than resolve to nothing.

The list and mapping selectors differ in how many entries they yield, and the difference follows from the containers. A list is ordered, so “the first entry matching” names a value an author can reason about. A mapping of named keys has no order the document defines, so there is no first entry to name; selecting from it therefore yields every match. A consumer that cannot represent several values MUST treat such a reference as unresolvable (below) rather than pick one.

Timing. Interpolation is resolved after include merge (§4.9) and before the resulting values are interpreted as fields. A reference in an included fragment therefore resolves against the including document.

Scope. Only string scalars are interpolated. A reference that resolves to a mapping, a list, or nothing at all is left verbatim, as is a reference whose address is not well-formed.

A reference resolving to several scalars is left verbatim by a consumer substituting into a single string. A consumer whose output is a repeating structure — one line of a code block, one entry of a list — MAY instead emit one copy per value; where several such references appear in one string, the result is their cross product. Emitting one copy per value and silently substituting only the first are different behaviours, and a consumer MUST NOT do the latter.

The verbatim rule is what makes cohabitation work: a ${…} belonging to another actor — a shell variable, a build-system variable, a CI matrix placeholder — is not a field address, so it survives untouched for that actor to resolve later. A consumer MUST NOT error on an unresolvable reference.

Repeat span. $[ + a field address + | + a template + ] renders the template once per value the address names, joined by one space, in place — one flag per port inside a single command line, where emitting one line per value would be wrong. Inside the template each value is a scope: a reference resolves against that value first and falls back to the document root, and ${.} names a scalar value itself. A span whose address resolves to nothing, or whose template leaves any reference unresolved for any value, is left verbatim as a whole; a string with no | inside the brackets is not a span. The separator is a pipe rather than a colon because : inside a YAML plain scalar starts a mapping. Because a span is ordinary text to a consumer that does not implement it, its unresolved inner references keep it out of any output that drops unresolvable strings.

# ports: [{port: 9101}, {port: 8080}] → "--publish 9101:9101 --publish 8080:8080"- docker run $[org.projectfile.artifacts{kind=service}.ports[] | --publish ${port}:${port}] …

Optional span. $[? opens a span that renders as nothing, together with one space that directly follows it, when its address resolves to nothing. It is for a part of a line that an absent list merely shortens, where the plain span would drop the whole line. A template that leaves a reference unresolved still leaves the span verbatim.

Filter. A reference MAY end in | and a filter name, ${<address> | <filter>}, which transforms each resolved value. v1 defines one filter, env: it upper-cases the value and replaces every . and - with _, so o9s.postgresql.password becomes O9S_POSTGRESQL_PASSWORD, the environment variable that carries the secret of that name. A reference naming a filter the consumer does not know is left verbatim.

# secrets: [o9s.postgresql.password] → "--env O9S_POSTGRESQL_PASSWORD"; absent → nothing- docker run $[?org.projectfile.artifacts{kind=service}.secrets[] | --env ${. | env}] …

Cycles. A resolved value MAY itself contain references. A consumer re-resolves to a bounded depth of its own choosing and then leaves whatever remains verbatim. A consumer MUST NOT loop indefinitely.

Escaping. $$ denotes a literal $; $${x} therefore renders ${x} and is never resolved, and $$[ renders a literal $[.

Literals. A string meant for a shell, a build system or a CI runner that must keep its own ${VAR} SHOULD write $${VAR}. A bare ${VAR} stays verbatim only while no field answers to that address, so a field added later can silently rewrite it; the escape removes that dependency.

A complete command line, combining a repeat span, an optional span with the env filter, an escaped literal and a plain reference (non-normative):

# → "docker run --publish 9101:9101 --publish 8080:8080 --env O9S_POSTGRESQL_PASSWORD --volume ${HOME}/data:/data demo"- docker run $[org.projectfile.artifacts{kind=service}.ports[] | --publish ${port}:${port}] $[?org.projectfile.artifacts{kind=service}.secrets[] | --env ${. | env}] --volume $${HOME}/data:/data ${identity.name}

The vectors under spec/conformance/interpolation/ in the specification repository pin the expected output of every construct in this section, this example included.

Consumer obligation. Interpolation is an interpretation-time mechanism, not a document-content one: a consumer MAY skip it entirely and MUST NOT fail on a document that uses it, per §3.7. A producer MUST NOT write resolved values back into the document in place of the references — the reference is the authored content.

Example (non-normative):

identity:  name: ubuntulicense:  spdx: MITlinks:  - type: source-code    url: https://codeberg.org/b19/ubuntuorg:  projectfile:    readme:      # → "https://img.shields.io/badge/license-MIT-blue"      shields:        - name: license          img: https://img.shields.io/badge/license-${license.spdx}-blue          href: ${links[type=source-code].url}      installation:        # → "docker run --rm registry.example/ubuntu:latest"        commands:          - docker run --rm registry.example/${identity.name}:latest        # ${TAG} is a build-system variable, not a field address: left verbatim.        postfix: Pin a release with ${TAG} instead of `latest`.

.onion.i2p