Blueprint reference
The wire format: the envelope, the fifteen brick kinds, the archive, every diagnostic code, the plan endpoint and the install ledger.
The normative page: the grammar a document must obey, the endpoints that read it, and the tables the install ledger keeps. For what an install feels like from the outside, start at Install one; for the permissions each of these endpoints asks for, see what you need first.
A document declares its format on its first line, and there is exactly one:
emuview-blueprint-v1. Anything else is refused with a diagnostic naming it — including
emuview-notebook-v1 and its pre-rebrand spelling sveltesync-notebook-v1, which were
read until 2026-09-10 and are not read now. There is no converter: the whole of the
migration is rewriting the first line and the two front-matter keys tabled below. See
ADR 0017.
emuview-blueprint-v2 is refused too, and it is a different case: it named this same
grammar, unchanged, until 2026-09-11. ADR 0023 restarted the ordinal because no v1 had
ever existed under that name, and a document carrying the old id is refused rather than
read — the migration is rewriting the first line, and nothing else.
The full design, including the parts not built, is in the blueprints white paper; this page documents what is shipped.
#The document
A blueprint is YAML front matter (the envelope), Markdown prose, and fenced bricks.
---
format: emuview-blueprint-v1
id: job-board
name: Job Board
version: 1.0.0
params:
- name: prefix
type: identifier
namespace: true
- name: apiToken
type: secret
assemblies:
core:
title: Job board core
required: true
bricks: [jobs, poster]
---
Validation enforces these envelope rules:
idmatches^[a-z][a-z0-9-]{2,40}$andversionis semver.- Exactly one parameter carries
namespace: true. Everycollection,role,container,search-index,webhookandscriptthe blueprint creates must embed it in its name (${params.prefix}_jobs), and everynavitem must embed it in itslabel— the field that substrate is identified by. So two installs of one blueprint can coexist and an uninstall can tell its own resources from the project's. The three added by #546 —webhook,script,nav— are the ones with no unique index behind them, where a collision is not a refusal but a duplicate row that makes every later plan ambiguous.viewandfloware not checked: a unique index turns a collision there into a refusal. - Parameter types are
identifier,string,email,url,number,booleanandsecret. Asecretparameter may not carry adefault— a default is a persisted secret. A secret's value never appears in a document, in the install ledger, or in an export. ${params.name}is substituted at compile time, inside JSON string values only. The flow engine's{{ }}templates and JSONata{{= }}expressions pass through untouched, and so do the server-side filter variables such as$CURRENT_USER. Substitution works on parsed JSON, not text, so a parameter value cannot forge structure around itself.- Assemblies group bricks for partial installs, each one listing its brick ids. A
requiredassembly cannot be deselected, and there is no brick-level skipping. The same two words reach the wire and the install ledger —assemblieson the plan and install requests,assemblies_jsonandbrick_idin the ledger — so what you type is what every layer below calls it.
There are no other envelope keys. Two that older documents carry — publisher: and a
requires: block — are gone, and nothing ever read them: no registry stands behind a
publisher, nothing gated an install on requires.entitlements, nothing read
requires.optional, and requires.notebookApi was stored and never compared with
anything. A document carrying either is warned about it by name and installs
regardless — the field changes nothing either way, so refusing the document over it would
refuse one that is correct.
What a document needs, and what a platform has, are both answered without a declared
version: an unknown brick kind is refused at parse time with a message naming the fifteen
that exist, and every plan response carries a coverage object saying which kinds this
instance can install. See ADR 0020.
#Bricks
A brick is a fenced code block whose info string is:
emuview:<kind> id=<brickId> [needs=a,b] [title="..."]
emuview: is the only prefix the parser reads. A blueprint authored before the
EmuView rename opens sveltesync:<kind>, and that spelling was read until
2026-09-10, when #499 deleted it with the notebook format ids — the two name one
document generation, and shortening only one of the lists would leave a document
half-readable.
This is the deletion whose failure mode is silent, and it is why it could
only ship while nothing was installed: an unrecognised fence is kept as PROSE
rather than refused, so a document that lost its prefix still opens, still
validates, and installs nothing while saying nothing. What makes it survivable is
that a document old enough to carry sveltesync: also carries a v1 format:,
and is refused on line 2 before any fence is reached.
The body is JSON in the same wire shape the platform's own API takes for that kind — a
collection brick is the body of POST /api/v1/collections, a flow brick is a flow
export document, and so on. The fifteen kinds are collection, access, grants,
role, records, flow, script, lens, attachment, renderer, view, nav,
webhook, search-index and container.
Each kind compiles to a call against one existing endpoint, so a blueprint can never do anything the API cannot, and every call is checked by that route's own permission rules. The compiler's kind-to-endpoint table is also what the plan's permission pre-flight is generated from, with a test that fails whenever the two disagree about what a call needs.
Two rules keep the consent screen honest:
- A collection brick must state
accessPreset: privateexplicitly. Omitting it is not the same asprivate: the create endpoint would fall back to the project's default access setting and produce grants the blueprint never named. - A brick of an unknown kind is refused, never skipped. A silently dropped
grantsbrick would install a working application with no access control.
#An archive carries a document
A blueprint is exactly one Markdown document — one .ebp.md, one source_md, one
doc_hash. That does not change. What may change is how the document arrives: every
source-taking endpoint (validate, plan and open) accepts a zip archive carrying it, in
place of the text.
{ "archive": "<the zip archive, base64-encoded>" }
The archive is unpacked at the door and the document inside it is passed on as a plain
string, so the answer is identical to posting that document directly: the same
diagnostics, the same plan, the same doc_hash, the same ledger row. The archive is
transport, not identity.
source and archive are mutually exclusive. A request carrying both is a 400:
accepting both would mean picking one, and a caller who sent two documents has no way to
learn which was installed.
#What an archive may hold
Exactly one .ebp.md, and nothing else. Any other member — a payload file, a
README.md, a directory entry — is refused with a diagnostic that names it. The document
may sit at any depth and take any name ending .ebp.md.
An archive is an attack surface and is treated as one. Every refusal carries a stable
archive.* code in details.code:
| Code | Status | Meaning |
|---|---|---|
archive.not_a_zip |
400 | The bytes carry no PK magic number |
archive.invalid_base64 |
400 | The archive field is not base64 |
archive.unreadable |
400 | Truncated or corrupt |
archive.too_large |
413 | The archive exceeds 4,000,000 bytes |
archive.too_many_members |
400 | More than 64 members |
archive.unsafe_member_name |
400 | A member name is absolute, or traverses with a .. segment |
archive.nested_archive |
400 | A member is itself an archive |
archive.member_too_large |
413 | A member declares or inflates to more than 1,000,000 uncompressed bytes |
archive.no_document |
400 | No .ebp.md |
archive.multiple_documents |
400 | More than one .ebp.md; both are named |
archive.unexpected_member |
400 | A member that is not the document; it is named |
archive.empty_document |
400 | The document is empty |
archive.not_utf8 |
400 | The document is not valid UTF-8 |
The uncompressed cap is checked against the entry's declared size before the member is inflated, and again against what actually came out.
The strictness is deliberate and temporary in one direction only: a later release relaxes "nothing else" for files the document declares with a matching sha256, so that a blueprint can carry the images and templates it installs. The caps above do not move when it does.
#Validate
POST /api/v1/blueprints/validate
Parses and validates a document. It reads no project state and writes nothing, so any signed-in user may call it; the anonymous public principal is refused. The document is capped at 1,000,000 bytes and the cap is checked before parsing.
{ "source": "<the blueprint markdown>" }
or, equivalently, { "archive": "<base64 zip>" } — see
An archive carries a document.
A document that fails validation is still a well-formed request: the response is 200
with ok: false and the diagnostics. 4xx is reserved for the call itself being wrong,
which is the distinction an authoring loop depends on — a 400 means "fix your request",
a 200 with diagnostics means "fix your document".
| Status | Meaning |
|---|---|
| 200 | Parsed; read ok and diagnostics |
| 400 | Body is not JSON, source is missing or not a string, or the archive was refused |
| 401 | Not signed in |
| 413 | source exceeds 1,000,000 bytes, or the archive exceeds its cap |
{
"data": {
"ok": true,
"diagnostics": [],
"blueprint": {
"id": "job-board",
"name": "Job Board",
"version": "1.0.0",
"publisher": null,
"params": [
{
"name": "prefix",
"type": "identifier",
"namespace": true,
"required": false,
"secret": false
},
{
"name": "apiToken",
"type": "secret",
"namespace": false,
"required": false,
"secret": true
}
],
"assemblies": [
{ "key": "core", "title": "Job board core", "required": true, "bricks": ["jobs", "poster"] }
]
},
"bricks": [{ "id": "jobs", "kind": "collection", "needs": [], "title": null }]
}
}
The response is a summary, never the document: the source is not echoed back, because it may carry a secret parameter's prompt or content a reader pasted in.
#Diagnostics
Each diagnostic is { severity, code, message, line, brickId }, with line and brickId
null when they do not apply. Codes are stable — a tool (or a model in a write, validate,
fix loop) should branch on code, not on message.
| Code | Meaning |
|---|---|
envelope.missing, envelope.unterminated |
No front matter, or the block never closes |
envelope.unsupported_format |
format is not emuview-blueprint-v1; the message names what to write |
envelope.removed_key |
Warning: publisher: or requires: — read by nothing, so ignored |
envelope.unsupported_syntax |
YAML anchors, aliases or merge keys — refused, not ignored |
envelope.invalid_id, envelope.invalid_version |
id outside the identifier grammar; version is not semver |
params.no_namespace, params.multiple_namespace |
Exactly one namespace parameter is required |
params.secret_default |
A secret parameter carries a default |
params.unknown_reference |
${params.x} names a parameter the envelope does not declare |
naming.missing_namespace |
A created resource's name — a nav item's label — omits the namespace |
brick.unknown_kind |
Refused rather than skipped |
brick.missing_id, brick.duplicate_id, brick.unterminated |
A brick with no id, two bricks sharing one, or a fence that never closes |
brick.invalid_json |
The body is not JSON |
brick.unknown_dependency, brick.dependency_cycle |
needs names a brick that does not exist, or forms a cycle |
assemblies.unknown_brick |
An assembly lists a brick that does not exist |
assemblies.orphan_brick |
Warning: a brick belongs to no assembly, so it installs unconditionally |
access.preset_required, access.preset_not_private |
A collection brick omits accessPreset, or sets one other than private |
plan.version_downgrade |
Warning, from plan only: the document is older than the installed version |
The nine brick.* and assemblies.* codes are the ones that name a brick or an assembly.
They are the reason the rename reached this far: a code is as author-facing as a key,
printed in this table and shown beside the message on the consent screen, so a person who
writes bricks: and is corrected in a vocabulary their document does not use has been
taught it anyway (ADR 0017).
plan.version_downgrade is the one code in that table validate never returns. It needs an
install to compare against and validate reads no project state, so only plan
raises it — and what it does and does not catch is on
Update and uninstall.
#Plan
POST /api/v1/blueprints/plan
Compiles the document, pre-flights every call it would make against the caller's
permissions, reads the live state of each resource it touches, and returns a verdict per
brick against the install ledger's baseline. Nothing is written. The same size cap and
400/401/413 responses apply as for validate, plus 403 without the read permission.
Planning enumerates schemas, roles, permission rows and flows in one answer, which is a builder's view of the project rather than an editor's.
{
"source": "<the blueprint markdown>",
"params": { "prefix": "jb" },
"assemblies": ["core"],
"secretsProvided": ["apiToken"]
}
params— values for the non-secret parameters. Keys the envelope does not declare are ignored: honouring them would let a caller inject a substitution the document never named.assemblies— the assembly keys to install; omit it for all of them. Required assemblies are included regardless.secretsProvided— the names of secret parameters the installer already holds a value for. The plan reports which are still missing and never learns any value.
A plan needs a person. A service API key — one with no bound user — is answered
403 install_needs_person, before any live state is read. Every verdict is computed
against the installer's own power, and a key that is nobody cannot be an installer; the
fix is a delegated key or the dashboard, not another grant.
A secret's value is refused. A request whose params carries a value for a secret
parameter is answered 400, naming the parameter and not the value. A plan never needs it:
secrets compile to a ${secret:<name>} placeholder, so every hash and verdict is identical
with or without the credential. Refusing is safer than accepting-and-stripping, which would
still put the value in this request's memory and logs.
{
"data": {
"ok": true,
"installable": true,
"diagnostics": [],
"blueprint": {
"id": "job-board",
"name": "Job Board",
"version": "1.0.0"
},
"coverage": {
"supported": [
"collection",
"access",
"grants",
"role",
"records",
"flow",
"script",
"lens",
"attachment",
"renderer",
"view",
"nav",
"webhook",
"search-index",
"container"
],
"unsupported": []
},
"baseline": null,
"verdicts": [
{
"brickId": "jobs",
"kind": "collection",
"verdict": "CREATE",
"reason": null,
"changedPaths": [],
"warnings": [],
"resourceRef": null,
"contentHash": "3f9c…",
"actionIds": ["nb:plan:jobs"],
"autoApply": true,
"requiresConsent": false
}
],
"orphans": [],
"actions": [],
"missingSecrets": []
}
}
ok— the document validated. When it is false,installableis false,verdictsandactionsare empty, anddiagnosticssays why; the request itself was fine, so the status is still200.installable— every brick has a verdict an executor could act on: noBLOCKEDbrick and no error.coverage— the brick kinds that have a live reader and those that do not.baseline— the earlier install of this blueprint in this project whose ledger rows were used as the comparison baseline, or null when there is none.verdicts— one per brick. The nine verdicts and what each one means for the person reading the plan are tabled under reading the verdicts.changedPathslists the key paths that differ; no resource body from either side is ever returned.orphans— resources the ledger attributes to this blueprint whose brick no longer exists in the document. Deletion is presented, never applied.actions— the compiled calls in dependency order, with every secret as a placeholder. Agrantsbrick is never auto-applied whatever its verdict: it comes back withautoApply: falseandrequiresConsent: true.missingSecrets— the secret parameters not listed insecretsProvided.
#Blocked
A BLOCKED verdict carries a named reason rather than a guess, and any BLOCKED brick
makes the whole plan non-installable:
| Reason | Meaning |
|---|---|
plan.no_reader |
The brick kind has no live reader on this server. Neither NOOP nor CREATE is ever returned for it, because both are answers a caller would act on. Every kind shipped today has one, so this reason is reserved for a format that has moved ahead of a deployment. |
plan.no_update_path |
The brick is an UPDATE — the document changed it — and this server has no way to re-apply a brick of that kind over a resource that already exists. Which kinds those are, and what to do about one, is on Update and uninstall. |
live.ambiguous_by_slug |
Two lens templates in the project share a slug. POST /api/v1/lens/import-json treats a slug as unique within a project, so which one the brick addresses cannot be decided. |
live.ambiguous_by_label |
Two instance-scope navigation items share a label. A navigation item has no key of its own, so its label is the only identity a blueprint can address it by. |
live.nested_item |
The navigation item with this label sits inside a group. A nav brick installs at the top level and cannot express a parent, so the two are not comparable. |
live.collection_mismatch |
A saved view of this name exists over a different collection. View names are unique per project, so the brick would neither create nor update it. |
attachment.lens_missing |
The brick's lensId names no template in this project. POST /api/v1/lens/attachments 404s on that check, so no create is promised. |
brick.late_bound_lens |
An attachment brick names its lens with a ${ref:…} placeholder. This server does not resolve one at plan time. |
read.permission_missing |
The caller cannot read the resource; the message names the missing grant, for example collections/jb_jobs:read. The rest of the plan is still computed. |
read.field_scope_narrowed |
The caller's read permission carries a field allowlist, so the live read is a subset. A verdict over a subset would assert something about fields the caller was not allowed to see. |
params.unresolved |
A parameter the brick references has no value, so the brick would address the wrong resource entirely. |
grant.escalates_installer |
A grants brick would give a role a grant you may not hand out — a wildcard resource, or write access to system/roles, system/api-keys, system/users or system/tenants. The message names the grant and the role. The role editor refuses the same grant from you, so an install is not a way round it. |
flow.run_as_not_delegable |
A flow brick's run_as delegates more than you hold. The same subset rule POST /automate/flows/import applies, checked before anything is created rather than halfway through. |
A read permission carrying an item_filter the read path cannot apply is reported as a
read.item_filter_not_applicable warning rather than being silently ignored.
#Coverage and per-kind limits
Every one of the fifteen brick kinds has a live reader, so coverage.unsupported is empty
and plan.no_reader is a reason this server no longer returns. The response still
publishes both sets: a client renders coverage from them rather than inferring it from a
BLOCKED row, and the next brick kind the format gains will arrive before its reader does.
Seven of the fifteen carry a limit worth knowing before you write a brick against them. Each is a refusal or a disclosure rather than a silent approximation:
| Kind | Limit |
|---|---|
attachment |
The lens is addressed by its server-assigned lensId, which is what POST /api/v1/lens/attachments takes. A late-bound ${ref:…} is refused (brick.late_bound_lens) and an id that names no template in this project is BLOCKED rather than planned as a create. |
nav |
Top-level items in the shared instance tree only. A brick declaring parentId or scope is refused; a live item that has been moved inside a group is BLOCKED. sortOrder is not compared — the route appends rather than defaulting, so no brick can predict it. |
lens |
status, enabled and the published version are not compared, because a lens brick has no key that asks for a rung — the install checklist publishes as a separate step. A live page that is not published at its current version is disclosed as a lens.not_published warning instead. |
access |
The config must state sharing explicitly. Omitting it means "keep whatever this instance already has", so the same brick would apply different policies on different instances — and turning sharing off revokes every share already handed out. A brick without it is BLOCKED. |
records |
Rows are identified by a natural key (slug, name or title, or a declared "key"), because record ids are server-generated. At most 200 rows per brick, every row declaring the same columns, no @ref: values and no secret parameters. See below. |
script |
max_cpu_ms and max_memory_mb are not compared: a create defaults the CPU ceiling to the project's automate quota, which no document can predict. The live values are disclosed as script.limits_not_compared. allowed_hosts IS compared, sorted. |
webhook |
Neither credential is compared. The signing secret is reported as hasSecret and custom headers as their NAMES only, so a rotated secret or a changed header value is invisible to a verdict — and an export carries neither, which is why installing one unedited lands without them. |
container |
storageConnectionId and bucketName are provisioned on the instance and imageSizes is written by PATCH, not by the create this brick performs. All three are disclosed as container.instance_local_members rather than compared. |
#Seeding rows
A records brick is the only kind that installs many resources from one brick, and both
halves of the ledger know it: settle writes one blueprint_resources row per seeded
record, keyed on the record's own id, and a plan reassembles all of them into one
baseline before computing the brick's verdict. idx_blueprint_resources_current is unique
on (project, blueprint, brick, resource_ref) precisely so that works.
The brick is an addressing envelope — {"collection": "…", "key": "slug", "rows": [ … ]} —
and only the columns the rows declare are compared, so the defaults a create fills in never
appear as a difference. What it refuses:
| Reason | Why |
|---|---|
brick.no_row_identity |
No slug, name or title, and no declared "key". Without one, no later question about the row — diff, update, uninstall — is answerable. |
brick.duplicate_row_identity |
Two rows share an identity, so they cannot be told apart afterwards. |
brick.ragged_rows |
The rows do not declare the same columns. A column one row omits would be compared against whatever the create defaulted it to. |
brick.too_many_rows |
More than 200 rows; POST /api/v1/collections/:name/records/bulk refuses the call. |
brick.late_bound_ref |
An @ref:collection:identifier value. The resolver is shipped in the seed CLI and designed-only here, so the literal string would be stored. |
brick.secret_in_seed |
A secret parameter in a seeded value. Seeded values are recorded in the ledger baseline, which is stored client-readably by design. |
live.translatable_column |
A translatable column. The bulk create folds a scalar into a {locale: value} map using the instance's default locale. |
live.ambiguous_by_key |
The collection already holds two live rows under one seeded key. |
#Round-trip guarantee
For every kind that has a reader, reading a live resource, emitting it as the brick an
export would produce, parsing that back as a blueprint and planning it against the same
project proposes no change: NOOP where a ledger row already claims the resource,
ADOPT with an empty changedPaths where none does. The contract exists because a phantom
UPDATE on a brick nobody touched teaches people to stop reading the plan, including on
the day it says something real.
It rests on one canonical form for both sides of the comparison: key order is ignored,
array order is significant (field order decides column order), both spellings of a JSON
field (type: json, and type: text with interface: json) collapse to type: "json",
and the server-derived resultType on lookup and rollup fields is stripped because a
document never carries it.
#The install ledger
Two project-scoped tables record what an install actually applied, as opposed to what it claimed:
blueprint_installs— one row per install run: blueprint id and version, the document's hash and source,params_json(secret parameters recorded by name only), the selected assemblies, and a status ofin_progress,partial,complete,supersededoruninstalled.blueprint_resources— one row per resource a brick produced: the brick id and kind, the resource's reference and content hash, the canonical form as verified after the write (the diff baseline the next plan compares against), and a status ofpending,settled,failed,rolled_backordetached.
Writes are two-phase. A pending row is recorded before each resource is written, and
settled with the resource's hash after the write is verified; a failed write is recorded
with its reason, and a pending row that never settles is swept to failed rather than left
as a claim. Superseding keeps the predecessor row and points it at its successor, so the
evidence of what an earlier version applied is never deleted.
That sweep is the blueprint-installs task on the hourly
maintenance tick. An installer that dies mid-run — a
closed tab, a killed terminal — reports nothing, so nothing on the request path can close
its run. The tick finds runs left in_progress whose pending rows have gone unspoken for,
marks those rows failed with the outcome recorded as unknown (never settled: nobody
read the resource back, and inventing a baseline would make every later plan diff against a
fiction), and closes the run as partial. The bricks that did land keep their verified
rows, so what you read afterwards is per-brick truth rather than a guess.
Every statement carries the project id: a ledger row is addressed by an opaque id, and the project scope is what stops one project's install from being read or rewritten by guessing another's. The routes below are the ledger's API; the plan endpoint reads it for the baseline.
#Installs
POST /api/v1/blueprints/installs
POST /api/v1/blueprints/installs/:id/settle
GET /api/v1/blueprints/installs
GET /api/v1/blueprints/installs/:id
GET /api/v1/blueprints/installs/:id/uninstall
POST /api/v1/blueprints/installs/:id/uninstall
These six routes are mounted under /api/v1 only. The project is always the caller's; no
route takes a project from a parameter, and an install id from another project answers
404 on every route, indistinguishable from an id that does not exist.
Open, settle and both uninstall routes require a person: a service API key, which has
no bound user, and the gateway's internal system identity are refused with the typed error
install_needs_person before their grants are consulted, because an install runs as the
installer and the resources it creates are owned by them. Settle is further bound to the
person who opened the run: anyone else holding the install permission gets 403. An
uninstall is deliberately not bound to them — it records only status changes, and requiring
the original installer would mean a blueprint installed by somebody who has since left
could never be removed. An anonymous caller gets 401 on all six.
There is no update route. An update is an install: see Update and uninstall.
#Open
{
"source": "<the blueprint markdown>",
"params": { "prefix": "jb" },
"assemblies": ["core"]
}
source may be replaced by archive, with the parameters and assemblies where they
already are. The run's source_md and doc_hash are then computed over the document that
came out of the archive, which is byte-for-byte the document that went in.
Writes the run row and one pending resource row per brick of the selected assemblies,
before the executor makes a single call. The response is 201 with the run id and one
{ id, brickId, brickKind, resourceKind } per pending row; those ids are what settle takes.
A document that does not validate, or does not compile with the parameters given (an
unresolved parameter, say), is a 400 carrying the diagnostics, unlike plan, which answers
200 with ok: false — planning is a question and opening a run is a write.
A value for a parameter the envelope declares type: secret is refused with 400, as plan
refuses it. The ledger records a secret by name only, and the executor substitutes the
value on its own side; nothing here ever needs it. A value for an identifier parameter
must be one: a lowercase letter followed by up to eleven lowercase letters, digits or
underscores. Both rules apply to plan as well.
#Settle
{
"outcomes": [
{ "resourceId": "…", "ok": true },
{ "resourceId": "…", "ok": false, "error": "HTTP 409 from POST /api/v1/collections" },
{ "resourceId": "…", "skipped": true }
],
"final": true
}
The server does not take the client's word for what landed. For each outcome reported ok,
it recompiles the brick from the document the run stored, checks its request shape the way
plan does, reads the resource back through the same substrate reader the plan uses, as
the caller, canonicalises what it found and computes the content hash itself. A resource
reported created but absent on read-back is recorded as failed, and so is one whose brick
plan would have refused. One the installer cannot read back, because the read permission is
missing or the reader refused, settles with a null ref, a null hash and a detail
saying why, so the next plan shows it as unverifiable rather than pristine; the client's
claim is never written for a kind the server can read.
That read-back rests on the run's own stored document, and if THAT cannot be recompiled the
settle fails whole rather than per row: no resource is settled, the run stays
in_progress, and the response is a 500 with the code blueprint_source_unreadable and
a details.reason of source_absent, source_unparseable, source_invalid or
compile_failed. The test for "still readable" is the one the open route applies — a
stored document that would no longer be accepted for install is not one the server will
verify against, which is what catches a document truncated after its front matter: it still
parses, and merely has no bricks left. The alternative — settling every row ok with a
null ref and a null hash, which is what the server used to do — recorded "could not verify"
as "verified as nothing": it would have made everything the run created permanently
unrevocable, since a row with no ref can only detach, and left the ledger reporting a
settled install whose record of what it did was a fiction. The refusal is safe to retry
once the document is restored. A brick simply MISSING from a document that read fine is a
different thing and keeps its per-row answer, settling unverified with
detail: settle.cell_unknown.
The stored baseline never carries a credential. The executor substitutes a secret's value
before the create call, so the live resource holds it; the server puts every secret back to
its ${secret:<name>} placeholder, guided by the brick's own form, before storing and
hashing applied.
resourceRef may be sent with an ok outcome and is consulted only for a brick kind this
server has no reader for. The row then settles with the client's ref, a null hash and
detail: settle.no_reader; without a ref such an outcome is a 400. Every kind shipped
today has a reader, so in practice the server's own read-back decides every ref.
A records brick settles differently: its one pending row is REPLACED by one settled row
per seeded record, each carrying that record's id as its resource_ref and a
self-describing {collection, key, row} baseline. The placeholder is deleted rather than
settled, because the number of resources a seed produces is not knowable until the create
returns their ids.
ok: false records the failure with its reason. skipped: true deletes the pending
row: a brick that was never attempted, because its assembly was deselected or the preflight
refused it, leaves no verdict behind. Each outcome is exactly one of the three shapes, and
a body naming the same resource twice is a 400. final: true closes the run as
complete, or partial when any row is still pending or failed. A closed run refuses
further settles with 409. A settle delivered twice changes nothing: every update is
guarded on the row still being pending, and a replayed skipped for a row the first
delivery already deleted is ignored.
#Uninstall
GET /api/v1/blueprints/installs/:id/uninstall returns what removing the run would do, one
entry per resource it still owns. What each disposition means is on
Update and uninstall:
{
"installId": "…",
"blueprintId": "job-board",
"empty": false,
"resources": [
{ "resourceId": "…", "brickId": "jobs", "brickKind": "collection", "disposition": "detach" },
{
"resourceId": "…",
"brickId": "poster-grants",
"brickKind": "grants",
"disposition": "revoke",
"action": {
"request": {
"method": "DELETE",
"path": "/api/v1/roles/poster/permissions/grants?blueprintId=job-board"
}
}
},
{
"resourceId": "…",
"brickId": "relay",
"brickKind": "webhook",
"disposition": "revoke",
"action": { "request": { "method": "DELETE", "path": "/api/v1/webhooks/wh_7" } }
}
]
}
A revocation is sent by the client, as an ordinary API call under its own credential, with
the same X-Guide-Action-Id lineage an install's calls carry. Then
POST /api/v1/blueprints/installs/:id/uninstall records what happened:
{ "removals": [{ "resourceId": "…", "revoked": true }] }
Every row of the run is detached, and the ones reported revoked: true are recorded
rolled_back instead; a row the body does not mention detaches too, because detaching is
the default and an omitted row is not a row that was kept. A revoked: true for a kind the
plan never offered a revocation for is refused with 400 rather than believed, and a
revocation reported as failed detaches with the reason written into the row's detail, so
a door the uninstall could not close is visible rather than silent. The run's status becomes
uninstalled. A run that is still in_progress is refused with 409; anything else is
safe to re-run, because every status change is guarded on the row still being settled.
#Read
GET /api/v1/blueprints/installs?limit=50&offset=0 lists the project's runs newest first,
without their documents. GET /api/v1/blueprints/installs/:id returns one run with its
document, its resources in creation order and per-status totals. Both present params as
{ values, secrets }, where secrets is the list of secret parameter names; a value under
a secret's name is stripped on the way out whatever the column holds, which is the rule the
ledger library cannot enforce on its own.