Reads and writes the versioned JSON envelope every artifact is wrapped in.
A bare payload on disk tells a reader nothing about which version of DocShell produced it or when. Every artifact is therefore wrapped:
{
"schema_version": "doc-shell/v2",
"generated_at": "2026-08-05T09:12:44.000000Z",
"generation_id": "Lve95gjOVATpfV8EL5X4nx",
"data": { ... }
}read/1 unwraps that and hands back only data, having first checked that
the version matches what this build of DocShell understands. A file written
by a future schema comes back as {:error, :unsupported_schema_version}
rather than as plausible-looking data with fields quietly missing — which is
the failure mode worth engineering against, since artifacts are read by
renderers in other repositories on their own release cadence.
Canonical bytes
Files hold compact canonical JSON: object keys sorted by UTF-8 bytes and no
insignificant whitespace, as produced by DocShell.Json.Canonical. The
bytes on disk are therefore exactly the bytes collection digests are
computed over, and two builds of the same content differ only in their
envelope fields. Pass pretty: true to write/3, or pretty: true to
DocShell.Build.run/1, when a person needs to read the files; decoded
values and digests do not change.
encode/2 and encode_envelope/4 return a DocShell.Artifact.Encoded
value. A build encodes each payload once, hashes those bytes, and splices
them into the envelope, so no artifact is encoded twice.
Atomic writes
write/2 writes to a temporary file in the destination directory and renames
it into place. A reader — typically DocShell.Web.Cache reloading while a
build runs — therefore sees either the previous artifact or the complete new
one, never a half-written file. The rename is atomic on POSIX filesystems
within a single filesystem, which is why the temporary lives beside its
target rather than in the system temp directory. The temporary is opened in
raw mode and the encoded iodata is written to it directly, without first
being copied into one binary or through a file server process.
Usage
:ok = DocShell.Artifact.write("priv/doc_shell/public/guides.json", entries)
{:ok, ^entries} = DocShell.Artifact.read("priv/doc_shell/public/guides.json")Producers should go through this module rather than encoding JSON themselves, so that a schema-version bump is one change instead of one per artifact.
Summary
Functions
Decodes a JSON envelope, rejecting ambiguous duplicate object keys.
Encodes a payload as the bytes of an enveloped artifact file.
Wraps payload bytes that are already canonical JSON in an artifact envelope.
Encodes a term as the bytes write_raw/3 would write, with no envelope.
Wraps a payload in the public artifact envelope.
Creates an opaque identifier for one complete artifact generation.
Reads an artifact and returns its payload with the envelope removed.
Reads an artifact and returns the whole validated envelope.
Writes a payload to path as an enveloped canonical JSON artifact.
Writes a term to path as JSON with no envelope around it.
Functions
Decodes a JSON envelope, rejecting ambiguous duplicate object keys.
@spec encode(term(), keyword()) :: {:ok, DocShell.Artifact.Encoded.t()} | {:error, term()}
Encodes a payload as the bytes of an enveloped artifact file.
Accepts the same :generated_at, :generation_id and :pretty options as
write/3, and returns exactly the bytes write/3 would write.
@spec encode_envelope(iodata(), DateTime.t(), String.t(), keyword()) :: {:ok, DocShell.Artifact.Encoded.t()} | {:error, term()}
Wraps payload bytes that are already canonical JSON in an artifact envelope.
data must come from DocShell.Json.Canonical.encode_to_iodata/1. The
envelope keys sort after "data", so splicing the bytes in yields the same
canonical document as encoding the whole envelope, without encoding the
payload a second time. Pass pretty: true for indented output.
@spec encode_raw(term(), keyword()) :: {:ok, DocShell.Artifact.Encoded.t()} | {:error, term()}
Encodes a term as the bytes write_raw/3 would write, with no envelope.
An Encoded payload is returned unchanged. Accepts pretty: true.
@spec envelope(term(), DateTime.t(), String.t()) :: map()
Wraps a payload in the public artifact envelope.
generated_at defaults to now and is accepted explicitly so a caller
stamping several artifacts in one build can give them all the same timestamp.
generation_id identifies the complete build that the artifact belongs to;
pass the same value for every file in one artifact tree.
Examples
iex> DocShell.Artifact.envelope(
...> %{"id" => "intro"},
...> ~U[2026-08-05 09:12:44Z],
...> "example-generation"
...> )
%{
"schema_version" => "doc-shell/v2",
"generated_at" => "2026-08-05T09:12:44Z",
"generation_id" => "example-generation",
"data" => %{"id" => "intro"}
}
@spec new_generation_id() :: String.t()
Creates an opaque identifier for one complete artifact generation.
Build pipelines should create one identifier and pass it to every
write/3 call in that generation. The value carries no ordering semantics.
Reads an artifact and returns its payload with the envelope removed.
Fails with {:error, :unsupported_schema_version} when the file was written
by a different contract version, and {:error, :invalid_artifact_envelope}
when the envelope is missing keys or carries an unparseable timestamp. File
and JSON errors are returned unchanged from File.read/1 and Jason.
Reads an artifact and returns the whole validated envelope.
Current-schema envelopes without generation_id remain readable. When
present, the ID must be a nonempty string. Previous schema versions,
including doc-shell/v1, are rejected. DocShell.Web.Cache requires the ID
on every file because a runtime snapshot needs a verifiable build identity.
Use this over read/1 when the envelope itself matters — serving an artifact
has to report the generated_at of the build that produced it, and
re-enveloping a bare payload would stamp it with the time of the request
instead.
Writes a payload to path as an enveloped canonical JSON artifact.
Creates the destination directory if needed, and swaps the file into place
with a rename so concurrent readers never observe a partial write. Pass
:generated_at and :generation_id when writing a complete tree so every
file carries the same build identity.
The file holds compact canonical JSON (see DocShell.Json.Canonical). Pass
pretty: true to indent it for reading by eye; the decoded value, and every
digest computed over it, is the same either way.
Invalid JSON values, duplicate encoded keys and exceptions from custom Jason encoders return tagged errors before replacing the destination. Protocol output is checked as JSON; an unsafe fragment cannot publish malformed bytes.
Writes a term to path as JSON with no envelope around it.
For documents that have to satisfy an external format rather than this
package's contract — an OpenAPI file a UI is pointed at, say. Artifacts a
renderer reads should go through write/2 instead. The term is written as
canonical JSON, or indented with pretty: true. A DocShell.Artifact.Encoded
payload is written verbatim.
Note that DocShell.Web.Cache rejects a directory containing an
unenveloped .json file, so these belong outside the artifact directories.