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:

  • id matches ^[a-z][a-z0-9-]{2,40}$ and version is semver.
  • Exactly one parameter carries namespace: true. Every collection, role, container, search-index, webhook and script the blueprint creates must embed it in its name (${params.prefix}_jobs), and every nav item must embed it in its label — 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 #546webhook, 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. view and flow are not checked: a unique index turns a collision there into a refusal.
  • Parameter types are identifier, string, email, url, number, boolean and secret. A secret parameter may not carry a default — 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 required assembly cannot be deselected, and there is no brick-level skipping. The same two words reach the wire and the install ledger — assemblies on the plan and install requests, assemblies_json and brick_id in 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: private explicitly. Omitting it is not the same as private: 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 grants brick 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, installable is false, verdicts and actions are empty, and diagnostics says why; the request itself was fine, so the status is still 200.
  • installable — every brick has a verdict an executor could act on: no BLOCKED brick 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. changedPaths lists 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. A grants brick is never auto-applied whatever its verdict: it comes back with autoApply: false and requiresConsent: true.
  • missingSecrets — the secret parameters not listed in secretsProvided.

#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 of in_progress, partial, complete, superseded or uninstalled.
  • 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 of pending, settled, failed, rolled_back or detached.

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.