Same code, same bytes
Regenerating without code changes produces a byte-for-byte identical file — clean diffs, every time.
The UIR (Universal Intermediate Representation) is the document Docuccino builds internally and
can export directly (--format=uir). It’s an OpenAPI-shaped JSON document with a little extra: a
stable identity for every operation and schema, a record of where each detail came from, and a
guarantee that identical code always produces identical bytes.
You never have to look at the UIR — the OpenAPI export is what you’ll usually ship. But it’s the foundation for Docuccino’s deterministic output and semantic diff, and it’s a clean input for richer tooling (changelogs, mock servers, AI agent tool schemas) that wants more than plain OpenAPI offers.
{ "$schema": "https://spec.docuccino.app/uir/1.0/schema.json", "uir": "1.0.0", "openapi": "3.2.0", "jsonSchemaDialect": "https://spec.openapis.org/oas/3.2/dialect/base", "info": { "title": "Billing API", "version": "2.1.0" }, "servers": [], "security": [], "tags": [], "paths": {}, "components": { "schemas": {}, "responses": {}, "securitySchemes": {} }, "x-docuccino": { "document": { "id": "doc:default", "configHash": "f7ea3a4314fc0a4e…", "contentHash": "7f6b185b0fd29b96…" }, "generator": { "name": "docuccino/laravel", "version": "1.0.0", "specVersion": "1.0.0" }, "content": { "pages": [], "nav": [] } }}Optional members are simply absent rather than empty: a document with no servers has no servers key,
and one with no content directory has no content.
There are no timestamps anywhere — they’d break determinism, so the format forbids them. The
x-docuccino subtree is strictly defined and closed to undefined members — a determinism
guarantee, since nothing non-deterministic can slip in. The format grows through versioned, additive
schema revisions rather than by readers tolerating unknown members (see spec hosting).
Every operation, parameter, named schema, response and content page carries an x-docuccino.id.
Identities are computed from meaning, never from file paths, line numbers or array positions, so they
survive refactors:
| Kind | Built from | Survives | Changes when |
|---|---|---|---|
op: |
document + method + path template (params normalized to {p0}, {p1}…) |
file moves, controller/method renames, path-param renames | the URL, method, or containing document changes |
par: |
operation + location + name | reorder, description edits | the parameter is renamed or moves between query and path |
sch: (named) |
class name and any generic type arguments (pin it with #[SchemaId]) |
file moves | the class is renamed without a pin |
sch: (inline) |
the schema’s structure | prose edits | the shape changes |
res: |
operation + status + media type | description edits | the status or media type changes |
page: |
the page’s slug | title and body edits | the file moves, unless you pin slug in frontmatter |
Ids read as <kind>:v1:<16 chars> — op:v1:sdqfknnscimbfjgg — where v1 is the identity algorithm
version, so a future algorithm can coexist with today’s ids. The document id is the one that stays
legible: doc: plus your config key.
This is what makes a meaningful diff possible: rename a controller and nothing changes; change a URL and the diff shows exactly that operation removed and added.
The format specifies a canonical ordering and serialization: fixed member order per object type, map keys sorted by code point, a fixed order of HTTP methods, parameters sorted by location then name, 2-space indentation, LF endings, and a trailing newline. The result:
Same code, same bytes
Regenerating without code changes produces a byte-for-byte identical file — clean diffs, every time.
Stable across upgrades
The content hash excludes tool metadata, so upgrading Docuccino moves only the generator
version line — the rest of the document is untouched (and the OpenAPI export, which drops that
metadata, is identical).
The document carries two hashes, and they answer different questions. contentHash digests the whole
document with the tool metadata excluded — it tells you the documentation changed. configHash
digests this document’s entry in your config file — it tells you the change came from configuration,
not from code.
Each contribution to the document is recorded — what produced it, the precedence layer it applied at, which fields it set, and the source location — so tooling (and you) can answer “why is this documented this way?”:
{ "producer": "attribute", "layer": "attribute", "fields": ["summary"], "source": { "file": "app/Http/Controllers/InvoiceController.php", "line": 34, "symbol": "App\\Http\\Controllers\\InvoiceController::index" }, "confidence": 1, "overrode": [ { "field": "summary", "value": "Index invoices", "producer": "docblock" } ]}producer names the specific source — inference, docblock, attribute, overlay, config,
fallback, or integration:<name> such as integration:query-builder. When a higher-precedence layer
wins a field, the value it replaced is kept in overrode rather than discarded, so the whole chain of
decisions stays readable.
Choose how much of that to emit with docuccino:export --provenance:
| Level | Emits |
|---|---|
full |
Every record, including the overrode trail. The complete audit. |
winners (default) |
Every record, without the overrode trails. |
none |
No provenance at all — smallest document, and immune to line-number churn. |
Provenance only exists in the UIR: the OpenAPI emitters always drop it.
Point content.dir at a folder of Markdown and Docuccino
compiles it into the document: guide pages with stable page: ids, plus a navigation tree derived from
your folder structure (overridable in frontmatter). Reference operations and schemas directly from your
prose with directives, and Docuccino resolves them at build time:
See ::operation{id="invoices.index"} for pagination, which returns ::schema{name="InvoiceData"}.Each resolved directive is rewritten with the target’s stable id, so a consumer can link to an operation without re-resolving anything. A directive that points at something that doesn’t exist becomes a diagnostic — so your guides can’t silently drift from your API. And because narrative lives in the document, prose changes show up in the diff and changelog too, not just API changes.
The content layer is UIR-only: plain OpenAPI has nowhere to put a page tree, so the OpenAPI export drops it. See Guides, pages & prose for the authoring side.
Continue to spec hosting for where the JSON Schema lives and how it’s versioned.