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
accessBrick surfaced a live bug in the consent screen — everyaccessBrick 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.tsinstalls it besidebillingon 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.
notifycannot 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.