Blueprint examples

Six shipped blueprints in one table, and one read end to end: what the format says, what the plan does with it, and where each document stopped.

Six complete emuview-blueprint-v1 documents ship in the repository, and together they are the reference for writing one. Each sits in its own directory under examples/blueprints/ beside a README.md that records what it builds, which of its Assemblies are optional, what an update and an uninstall do to it, and what it wanted and could not have.

This page does not repeat those READMEs. They are the detail and they are maintained beside the documents they describe; a second copy here would drift from them within a week. What this page has that no README can is all six at once — and one of them read end to end, as an author meets it rather than as a catalogue of what it installed.

#The six, compared

Shapes, not domains: the five written after billing were chosen so that no two have the same structure, and coverage was measured afterwards rather than aimed at (#479). The last column is the useful one — it is where the format ran out, per document, and every entry in it is written up under What it could not do, and why in that document's README.

Document Shape Brick kinds What it could not do
support-desk Content plus isolation. Two audiences on one queue, where a wrong row scope is visible from outside collection, role, access, grants, flow, script, view, nav Call the script it installs. run_script addresses a script by a server-generated id and the substitution grammar is ${params.<name>} and nothing else (#498)
editorial Content plus a workflow, reaching outward twice — a webhook row and a flow's own dispatch step collection, role, grants, lens, flow, webhook, nav Say "a draft needs only a headline, a published post needs a byline". required is a property of the column, not of the state, so it ships no event_filter Brick at all (#553)
catalogue Content at scale. 273 seeded rows across three records Bricks, a search index and a media container collection, records, container, search-index, renderer, view, nav Put anything in the container it built. A records Brick seeds rows and a container Brick makes the container; there is no file Brick for the bytes in between (#516)
marketing-site Presentation first. Public pages, with one small table behind one of them collection, grants, lens, renderer, view, nav Give a story its own page. That needs an attachment Brick naming a Lens the same document installs, which is refused outright (#498)
notify Behaviour only. It creates no Collection at all and is pointed at an existing one by a parameter webhook, flow Check that the Collection it was pointed at exists. Nothing verifies the parameter, so a typo installs green on all three Bricks and then delivers nothing, ever (#544)
billing An application that was never code, written from the substrates outward collection, records, role, grants, flow, view, nav Price what it bills. The usage ledger is behind the instance's own API and a flow's outbound HTTP is guarded against internal hosts, so its monthly run opens draft invoices with no money in them

Fourteen of the format's fifteen Brick kinds appear in that third column. The fifteenth is below.

#Why the walkthrough is support-desk

billing is the older document and the more complete one, and it is not the one to meet first. It was written for the plan machinery — from the substrates outward, to find out whether the format could build an application nobody had coded — so it agrees with the machinery by construction. A document that agrees with the thing it is demonstrating cannot tell you where that thing gives way, and giving way is what the other five were commissioned to find.

support-desk was written the other way round: a scenario picked on its own merits, before anybody looked at which Brick kinds were uncovered. It earns the walkthrough on three counts.

  • It made the machinery yield. Being the first committed document with an access Brick surfaced a live bug in the consent screen — every access Brick rendered zero lines, a blank tick beside a kind that regenerates a collection's permission rows. That is falsification working, and it is the argument for writing a document for its own sake.
  • It is checked, not asserted. tests/suites/system/blueprint-exit-bar.test.ts installs it beside billing on one Project, uninstalls it, re-installs it and finally revokes it for real, against a live gateway. Everything below is a description of what happens, not a reading of what the format permits.
  • It needs no setup. It creates both of its collections, so it installs into an empty Project. notify cannot lead for the opposite reason: it creates nothing, so there is nothing to watch it do until somebody hands it a Collection.

Read on for the document as a document. What it builds, brick by brick, and what it could not do are in its README.

#A walk through support-desk

The document is examples/blueprints/support-desk/support-desk.ebp.md: YAML front matter (the envelope), Markdown prose, and fenced Bricks. The prose is not decoration — it is what the person approving the install reads beside each tick.

#The envelope, and the parameter that can never change

Below format, id, name and version, the envelope declares the questions the install screen asks. support-desk asks two:

params:
  - name: prefix
    type: identifier
    namespace: true
    required: true
    default: desk
    prompt: Prefix for every table, role, flow and script this blueprint creates
  - name: queue_label
    type: string
    required: true
    default: Support
    prompt: Label for the sidebar entry — also how the plan identifies it

namespace: true marks exactly one parameter, and a lint requires every identifier the document creates to embed ${params.prefix}. That is what lets two installs coexist, and it is immutable for the life of an install: the value is recorded on the install row, and a plan computed with a different one is a fork — a fresh estate side by side — never an update.

queue_label is there because a namespaced name is not always where identity lives. navigation_items carries no unique constraint at all, so the plan identifies a sidebar entry by its label and refuses rather than guesses when two match. The rule generalises: wherever a kind's identity is not the name the prefix is glued to, the identity itself has to be something the installer can make unique, which means it has to be a parameter.

#Four Assemblies, and what a tick actually buys

An Assembly is a group of Bricks the installer accepts or declines as a unit, and it is the unit the consent screen counts. support-desk declares four: desk and people are required: true; promises and queue are not.

Declining an optional Assembly is not "install less of it and see" — the document has to say what is lost, in the README, in terms of rows and columns. Declining promises here leaves three datetime columns on the tickets table empty for ever, and the columns are still created, because adding a column later to a table with rows in it is a schema change and shipping three empty ones is cheaper than a migration.

The ordering inside an Assembly is not positional. A Brick names what it needs on its own fence — the comments collection opens emuview:collection id=comments needs=tickets title="Ticket comments", and needs is a dependency edge. A cycle in it is refused at validation rather than discovered as a half-finished install — the schema importer had to work around exactly that deadlock, where every action in a cycle stayed silently disabled.

#One sentence about rows, written with two mechanisms

"An agent sees every ticket; a requester sees their own" is one sentence, and support-desk needs both consent kinds to say it. This is the part of the document worth slowing down for, because the two kinds look interchangeable on the consent screen and behave differently everywhere else.

An access Brick is one collection's stored row scope:

{
	"collection": "${params.prefix}_desk_tickets",
	"config": {
		"version": 2,
		"owner_column": "created_by",
		"sharing": "off",
		"roles": {
			"${params.prefix}_desk_agent": { "access": "all", "write": true },
			"${params.prefix}_desk_requester": { "access": "own", "write": false }
		}
	}
}

own compiles to the platform's real ownership filter rather than to a comparison this document would then own a second copy of. But the access editor has one write switch covering create, update and delete, so "may open a ticket and may never delete one" cannot be said here at all.

That is what a grants Brick is for — the individual rows, filters and presets a scope cannot express:

{
	"resource": "collections/${params.prefix}_desk_comments",
	"action": "create",
	"item_filter": {
		"_and": [{ "visibility": { "_eq": "public" } }, { "requester": { "_eq": "$CURRENT_USER" } }]
	},
	"presets": { "visibility": "public", "requester": "$CURRENT_USER" }
}

Both kinds write permissions rows. Both need their own tick, and grants are never applied without one, at install, update or uninstall alike. The difference shows up twice later — once on update, once on the way out — and both times it follows the Brick kind, not the table the write landed in.

The last line of that grant is the one to notice. item_filter, fields and write_fields all NARROW what a caller may do; presets REWRITES what they sent, by assignment, on every write the grant permits. It is the strongest of the four and it is the one the consent screen does not name (#540) — a gap that is easy to miss precisely because this document uses it correctly.

#A flow declares the rung it wants

Both of the document's flows declare state: "active" and a slug. The state is three calls, not one: import writes a draft, publish creates the version row, and only then can activation succeed. A document that shipped a disabled automation would be asking the installer to finish the install by hand. The slug is the flow's identity within the Project, so a second install of the same document does not collide on it.

#What a plan says before anything is written

Nothing above is written until a plan has been computed and consented to. On a Project that has none of it, every one of the twelve Bricks comes back CREATE and the plan is installable. Install is then one ordinary API call per Brick, made as the person installing, under their own permissions — a Blueprint confers nothing its installer does not already hold. The ledger settles each Brick from the server's own read-back, re-reading the resource and hashing it rather than believing the client, and a second plan is NOOP throughout.

Change the document and re-plan, and the verdict depends on whether the kind has an update endpoint — three of the fifteen do. So a field added to tickets is UPDATE; a changed row scope on the same table is BLOCKED, and says so on the consent screen rather than halfway through.

#Uninstall is detach, and three rows are offered back

A Blueprint's uninstall never takes the Project's data. Most kinds in the format have no uninstall endpoint at all, so they stop being owned by the Blueprint and stay exactly where they are: both collections with every ticket and comment in them, the script still selectable, the view, the sidebar entry and both roles. The ledger rows move to detached rather than being deleted. The exit-bar suite asserts that directly, on a ticket written after the install by somebody who did not care that a Blueprint created the table — rather than asserting that a guard was called.

What IS offered back is what would otherwise keep acting, and the two reasons are different:

requester-grants is a door. One tick, never automatic: a permission a Blueprint handed out is one the Project did not have before, so leaving it is not keeping your data, it is leaving a door open. Ticking it deletes the Blueprint's own partition of the permissions table and nothing else.

triage and sla-sweep are already through one. Both are installed active, so without a tick they go on running after the Blueprint is gone. Ticking one sets it inactive — the flow stays, keeps its published version, and is one click from running again; only the activation the Blueprint applied comes off. A webhook Brick is offered on the same terms and deleted outright, because a webhook is a URL, an event subscription and a signing secret rather than anything the Project would miss.

And the two access Bricks stay applied — the requester is still confined to their own tickets. All three Bricks ledger as resource_kind: 'permissions', so a dispatcher keyed on the resource kind would revoke all three and take a collection's row scopes off on the way out. support-desk is the only document in the tree where that split can be checked at all, which is why it is the second fixture.

#Re-install, and the tick that claims it back

Plan the same document again after an uninstall and every Brick is ADOPT: the resources are there, no ledger row claims them, and re-installing would start claiming them. That is a consent kind of its own and it is never assumed. Accept it and nothing is written to any resource — the ledger simply learns who owns them again, and a third plan is NOOP.

#What billing is still the document for

It stays on the page as the reference implementation and as the standing exit-bar fixture, and two things are only visible in it.

Seeding. Its records Brick seeds four plans, and a seeded row needs a natural key: a record's id is the server's, so identity has to be a column the document chooses. Only the columns the seed declares are compared, so a default the create filled in never reads as a difference, while a column the seed does carry shows up as DRIFT named down to the row. One Brick becomes many ledger rows — one per seeded record — so an uninstall detaches each of them individually. catalogue is where that fan-out is exercised at scale.

The finding it made. Two current installs of one Blueprint block every later plan of it, including a plan of the first. The baseline is selected per (project, blueprint, brick) without consulting which install a row belongs to, so a second fork makes the plan refuse at every single-resource Brick. That is a property of the plan endpoint rather than of any document, which is why it is here and not in the table above.

A third document used to sit beside billing: a decomposition, the non-code parts of a build-time plugin re-expressed, which is how the platform proved that most of a "code plugin" wanted to be declarative in the first place. That argument is what let the plugin itself be deleted outright, in ADR 0015. The document outlived the plugin by a day and was then removed too, because a reference example that re-expresses a capability nobody is rebuilding reads as a live feature to everyone who finds it. Its argument is kept in the ADR.

#The Brick kind no document reaches

Fourteen of fifteen kinds are installed by the six documents. The one nothing reaches is attachment, and it is unreached for a reason rather than by omission.

An attachment Brick binds a Lens to a Collection for one context — record view, list view, create or edit form — and it takes lensId, a server-generated 32-hex id. ${ref:brick.id} is designed and not implemented: a Brick whose lensId is a placeholder is refused outright, and a literal id naming no template on this instance is BLOCKED. So an attachment Brick cannot bind a Lens the same Blueprint installs, and any document using one depends on a Lens it did not create, which makes it not a self-contained example.

Two independent routes arrive at that wall: marketing-site wanted a page per customer story and stopped, and support-desk's flow cannot call the script beside it for the same missing mechanism. Two routes to one question is evidence about the kind, so it is filed as a decision — #498, should a Brick be able to reference another Brick's resource? — rather than patched with a seventh document contorted to reach it.

#A complete document, short enough to paste

Short but complete: paste it into the authoring screen and it validates as it stands. It is support-desk reduced to one Assembly and one Brick; the real document carries twelve Bricks across four.

---
format: emuview-blueprint-v1
id: support-desk
name: Support desk
version: 1.0.0
params:
  - name: prefix
    type: identifier
    namespace: true
    required: true
    default: desk
    prompt: Prefix for every table, role and flow this blueprint creates
assemblies:
  desk:
    title: Tickets
    required: true
    bricks: [tickets]
---

## Tickets

The row everything else hangs off. This paragraph is what the person approving
the install reads beside this Brick's tick.

```emuview:collection id=tickets title="Tickets"
{
  "name": "${params.prefix}_desk_tickets",
  "accessPreset": "private",
  "fields": [{ "name": "subject", "type": "text", "required": true }]
}
```

accessPreset is spelled out on purpose. Omitting it is not the same as private — the server falls back to the Project's collections.default_access, so the same document would install different permissions on different instances and the consent screen could not say which.

#Running one against a gateway

# Validate and plan, without writing anything
curl -X POST "$GATEWAY/api/v1/blueprints/validate" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  --data "$(jq -Rs '{source: .}' < examples/blueprints/support-desk/support-desk.ebp.md)"

# The full lifecycle for both fixtures, against a local gateway
npx vitest run --config tests/vitest.config.ts tests/suites/system/blueprint-exit-bar.test.ts

The same walk in the dashboard is on Install one.