Requires a local Postgres — this guide is about the one adapter that can carry data across a shape change, so it runs against the real thing or not at all.
No Postgres installed? examples/directory/docker-compose.yml starts a
throwaway one in Docker and shows how to boot the directory example against
it (docker compose -f examples/directory/docker-compose.yml up -d --wait,
then PGUSER=hecks bundle exec hecks console subject=examples/directory).
That path is checked for the directory example only; this guide’s own live
examples have not been run against the container.
Your domain’s shape will change. Not might — will, the day it survives contact with a second requirement. The question that decides whether you can ship the change is not “does the new bluebook look right” — it is “what happens to the records that were written under the old one.” Most systems answer that question with a migration script, hand-written, untested against real data until it runs in production. This guide documents the other answer: the shape change is declared, the same way everything else here is declared, and a real tool tells you — before you boot — whether every old record survives it.
This is PostgresEra’s job specifically, not Postgres’s in general.
Plain Postgres has no era tracking — it boots whatever text it is
handed, the same as Memory, Sqlite, and Heki, none of which carry
a translation system either. PostgresEra is the one binding that
holds the source text a booted domain was born from, refuses on drift
between held text and booting text, and requires a deliberate
translations/*.bluebook file the moment a shape change would
otherwise silently reinterpret old rows.
This is not a hypothetical. examples/pizzas lived through exactly this,
in this repository, and the result is sitting in a real Postgres
database right now. The Pizza aggregate became Order, carrying a
nested Pizza value object — because a pizza has no identity of its
own (two identical Margheritas ARE the same value), while the order
does. The evidence follows below rather than a description of it:
```ruby boot Kernel.load(File.join(InMemoryDomain::ROOT, “examples/pizzas/bluebook/pizzas.bluebook”)) Kernel.load(File.join(InMemoryDomain::ROOT, “examples/pizzas/bluebook/pizzas.world”)) Hecks.hecksagon(“Pizzas”) do attaches “Governance” Pizzas::Order.persisted_by(“PostgresEra”) end Hecks.hecksagon(“Governance”) do Governance::RoleAssignment.persisted_by(“Memory”) Governance::RoleTransition.persisted_by(“Memory”) end
```ruby
require "pg"
db = PG.connect(dbname: "hecks_pizzas")
eras = db.exec("SELECT ordinal, label FROM hecks_eras ORDER BY ordinal").to_a
eras.map { |row| row["ordinal"] } # => ["1", "2"]
# "alpha" was written under era 1's shape — top-level price_cents, no
# nested value object. It reads back through era 2 correctly, or this
# sentence would be a lie the suite could catch.
row = db.exec_params("SELECT state FROM order_head WHERE id = $1", ["alpha"]).first
state = JSON.parse(row["state"])
state["pizza"]["price_cents"]["cents"].positive? # => true
db.close
Two eras, one database, and a record that predates the second era still answers correctly through it. That is the whole promise of this system, proven against data that was never touched to make this guide true.
The walkthrough below needs a shape to actually change, live, in front
of you — a real drift introduced on purpose, then scaffolded and
resolved. Pizzas’ real era 1→2 history above already happened; there is
no more drift left in it to discover. Banking’s real history has none
at all. Reproducing a controlled drift against either would mean
minting a fake historical era onto a domain whose whole value is being
the real, settled record — a different and larger kind of dishonesty
than a small, clearly-labelled scratch fixture. So this one section
uses a throwaway domain, built and torn down inside the walkthrough
itself, exactly the way spec/fixtures/eras/ and
spec/adapters/driven/postgres/lineage_spec.rb’s own era fixtures do
for the same reason — the one deliberate exception in an otherwise
real-corpus guide.
Three pieces, and you will use all three every time your shape changes:
hecks era.scaffold_translation <domain> — diffs the shape your
bluebook currently declares against the shape the database holds,
and writes an edge file with whatever it can infer confidently. What
it cannot infer — an aggregate that vanished and a differently-named
one that appeared, which look identical to “deleted, then created” —
it refuses to guess at. That refusal is not a bug you work around; it
is the one decision only you can make.hecks era.audit_translation <domain> — replays your edge against
every record the database actually holds and shows you a before/after
sample. It is the difference between “the rules type-check” and having
actually reviewed what happens to the data.The following walkthrough exercises all three, live, against a barn full of bins.
require "fileutils"
require "tmpdir"
GRANGE_DB = "hecks_doctest_grange"
GRANGE_DIR = Dir.mktmpdir("hecks-doctest-grange-")
admin = PG.connect(dbname: "postgres")
admin.exec("DROP DATABASE IF EXISTS #{GRANGE_DB} WITH (FORCE)")
admin.exec("CREATE DATABASE #{GRANGE_DB}")
admin.close
FileUtils.mkdir_p(File.join(GRANGE_DIR, "bluebook"))
File.write(File.join(GRANGE_DIR, "bluebook/grange.bluebook"), <<~BLUEBOOK)
Hecks.bluebook "Grange" do
vision "A barn keeps crates, and a crate is what it holds."
generic
aggregate "Crate" do
identified_by :label
attribute :label, Label
attribute :weight, Weight
value_object "Label" do
attribute :value, String
end
value_object "Weight" do
attribute :value, Integer
end
command "Store" do
attribute :label, Label
attribute :weight, Weight
emits "Stored"
end
end
end
BLUEBOOK
File.write(File.join(GRANGE_DIR, "bluebook/grange.hecksagon"), <<~HECKSAGON)
Hecks.hecksagon "Grange" do
Grange::Crate.persisted_by("PostgresEra")
end
HECKSAGON
File.write(File.join(GRANGE_DIR, "bluebook/grange.world"), <<~WORLD)
Hecks.world "Grange" do
realm "Doctest"
persisted_by("PostgresEra") do
database "postgres://localhost/#{GRANGE_DB}"
allow_superuser true # this guide runs as the ambient Postgres user — see "Who the fence can hold" below
end
end
WORLD
era_one = Hecks.boot(GRANGE_DIR)
era_one.dispatch_flat("Grange::Crate.Store", label: { value: "c1" }, weight: { value: 10 })
Grange::Crate.count # => 1
Era 1, minted, with one real crate in it — the same as any first boot.
Now the requirement lands: a crate’s weight is going to grow more
fields (a unit, eventually a tare), so it earns its own value object.
At the same time, Crate becomes Bin — closer to what the warehouse
actually calls it. Two changes at once, on purpose: this is the
discriminating case, the one hecks era.scaffold_translation cannot resolve
alone.
File.write(File.join(GRANGE_DIR, "bluebook/grange.bluebook"), <<~BLUEBOOK)
Hecks.bluebook "Grange" do
vision "A barn keeps bins, and a bin is what it holds."
generic
aggregate "Bin" do
identified_by :label
attribute :label, Label
attribute :contents, Contents
value_object "Label" do
attribute :value, String
end
value_object "Weight" do
attribute :value, Integer
end
value_object "Contents" do
attribute :weight, Weight
end
command "Store" do
attribute :label, Label
attribute :contents, Contents
emits "Stored"
end
end
end
BLUEBOOK
File.write(File.join(GRANGE_DIR, "bluebook/grange.hecksagon"), <<~HECKSAGON)
Hecks.hecksagon "Grange" do
Grange::Bin.persisted_by("PostgresEra")
end
HECKSAGON
scaffold = `bundle exec #{File.join(InMemoryDomain::ROOT, "exe/hecks")} query era.scaffold_translation #{GRANGE_DIR} 2>&1`
scaffold.include?("UNCLAIMED: Crate existed and now doesn't") # => true
There is the refusal, exactly as promised: Crate disappeared, Bin
appeared, and nothing tells the scaffold they are the same thing. It
wrote an edge file anyway — empty, waiting — because it would rather
hand you a file to fill in than guess and be wrong silently.
edge_path = Dir.glob(File.join(GRANGE_DIR, "bluebook/translations/*.bluebook")).first
edge_before = File.read(edge_path)
edge_before.strip.end_with?("do\nend") # => true
The edge is resolved by hand — this is the one decision that was always going to belong to a person, not the tool:
edge_source = File.read(edge_path)
label = edge_path[/\d+-(\h+)\.bluebook\z/, 1]
from = edge_source[/from: "(\h+)"/, 1]
resolved = <<~EDGE
Hecks.data_translation "Grange", from: "#{from}", to: "#{label}" do
aggregate "Bin", was: "Crate" do
move :weight, to: "contents.weight"
end
end
EDGE
File.write(edge_path, resolved)
was: "Crate" answers the identity question the scaffold could not;
move :weight, to: "contents.weight" answers where the one field that
crossed a value-object boundary now lives. Now the audit — the step
that turns “this type-checks” into confirmation of what actually
happens to the data:
audit = `bundle exec #{File.join(InMemoryDomain::ROOT, "exe/hecks")} query era.audit_translation #{GRANGE_DIR} 2>&1`
audit.include?('"weight":{"value":10}') # => true
audit.include?('"contents":{"weight"') # => true
audit.include?("AUDIT PASSED") # => true
Before and after, side by side, over the one real record this barn holds. That is not a type-check — it is c1’s actual weight, actually moved, shown before anything commits. Now the boot that mints:
era_two = Hecks.boot(GRANGE_DIR)
Grange::Bin.find("c1").contents.weight.to_h # => { value: 10 }
Grange::Bin.count # => 1
The record c1 wrote before Bin existed reads back through Bin,
nested exactly where the edge said it would be. Nothing about this was
a migration script running against production for the first time —
every step above ran against the one real record in this barn before
the mint committed.
admin = PG.connect(dbname: "postgres")
admin.exec("DROP DATABASE IF EXISTS #{GRANGE_DB} WITH (FORCE)")
admin.close
FileUtils.remove_entry(GRANGE_DIR)
true # => true
The Grange edge above used two of the nine things a translation rule
can say: was: (an aggregate renamed) and move (a field crossed a
value-object boundary). The other seven matter just as much, and each
one’s effect on a stored record can be shown without a second Postgres
round-trip — Hecks::Ports::Persistence::Lineage is the exact
code every mint runs internally, and it answers directly:
Move = Hecks::Bluebook::TranslationMove
Convert = Hecks::Bluebook::TranslationConvert
Retype = Hecks::Bluebook::TranslationRetype
Backfill = Hecks::Bluebook::TranslationBackfill
Entry = Hecks::Ports::Persistence::Entry
Lineage = Hecks::Ports::Persistence::Lineage
rename — a field keeps its shape, only its name changes:
lineage = Lineage.new({ cost: :amount })
entry = Entry.new(operation: "save", id: "a1", state: { cost: 100 })
lineage.translate(entry).state # => { amount: 100 }
drop — a declared, deliberate acknowledgment that a field’s data
does not survive. Not a silent vanishing — a line in the edge file that
says so:
lineage = Lineage.new({}, [], [], [:legacy_note])
entry = Entry.new(operation: "save", id: "a1", state: { legacy_note: "x", kept: true })
lineage.translate(entry).state # => { kept: true }
convert — a move whose value has nothing in common with its
replacement, so it needs a lookup table rather than a rename. Nested
state is read back exactly as a real adapter decodes it from JSON —
string keys, not symbols, which is why the value object below is built
that way:
lineage = Lineage.new({}, [], [Convert.new("kind.label", "kind.label",
{ "biz" => "business", "pers" => "personal" })])
entry = Entry.new(operation: "save", id: "a1", state: { kind: { "label" => "biz" } })
lineage.translate(entry).state # => { kind: { "label" => "business" } }
A value the table does not cover refuses the whole mint rather than carry something unrecognized into the new era — the exhaustiveness is the point:
lineage = Lineage.new({}, [], [Convert.new("kind.label", "kind.label", { "biz" => "business" })])
bad = Entry.new(operation: "save", id: "a1", state: { kind: { "label" => "mystery" } })
lineage.translate(bad) # ~> WiringError: cannot translate kind.label: "mystery" has no mapping
retype — the odd one out: it moves nothing at all. A value
object’s own TYPE name changed while its members stayed identical, and
since stored data never carries a type name, there is nothing to
translate — retype only tells the era diff that the two names mean
the same shape, so it stops asking:
lineage = Lineage.new({}, [], [], [], retypes: [Retype.new("Money", "Cash")])
lineage.retype?("Money", "Cash") # => true
lineage.retype?("Cash", "Money") # => false
backfill is the addition-side sibling of drop — a newly added,
required attribute has no from: at all, because old data never held
the field in the first place. default is what an existing record
reads until the next command against it writes a real value:
lineage = Lineage.new({}, [], [], [], backfills: [Backfill.new(:tier, "standard")])
entry = Entry.new(operation: "save", id: "a1", state: { name: "Acme" })
lineage.translate(entry).state # => { name: "Acme", tier: "standard" }
It never overwrites a value that is already there — only a record that genuinely predates the field reads the backfill:
already_written = Entry.new(operation: "save", id: "a1", state: { name: "Acme", tier: "gold" })
lineage.translate(already_written).state # => { name: "Acme", tier: "gold" }
Adding a required attribute with no default:, no list_of, and no
fully-defaulted value object refuses at boot until one of those, or a
backfill, explains what an existing record should read there — the
same refuse-at-declaration EraGuard already applies to a vanished or
retyped field, read on the addition side instead.
compute is the one rule with no in-process implementation at
all — its SQL expression is its only meaning, run inside the era’s own
compiled view, and Lineage#translate passes a computed field through
untouched on purpose. That is exactly why minting an edge with a
compute rule requires hecks era.approve_translation --confirm first: a
human has to look at the before/after sample, because nothing else can
verify it. If you reach for compute, budget the review — it is not
optional, and the mint refuses without it.
rekey is the aggregate’s own identity changing what it’s computed
from — identified_by :name becoming identified_by :email,
say. Ordinarily this refuses outright: stored ids were minted under the
old key, and nothing in the other seven rule kinds says “these rows are
the same entity under a new key.” A rekey rule is exactly that
declaration — SQL-only, like compute, with no in-process reference
implementation (Lineage#translate never touches an entry’s id,
only its state) and the same hecks era.approve_translation --confirm
requirement before it can mint. Unlike every other rule here, it takes
no from:/to: path — it isn’t moving or consuming a state field,
only recomputing what identifies the record:
ruby skip
aggregate("Member") do
rekey sql: "CASE ((__s -> 'name') ->> 'value') WHEN 'Chris Young' THEN 'chris@example.com' END"
end
The journal row itself never changes — aggregate_id stays whatever it
was minted under, forever, the same immutability every other rule here
already holds itself to. What changes is what the next era’s
compiled view resolves that row as, and what a fresh dispatch mints for
a brand-new record going forward. One known gap: hecks era.merge_tail’s own
conflict detection (tail_merge.rb#conflict_ids) does not yet
recognize a pre-rekey and post-rekey row as the same entity — merging a
domain whose history includes a rekey may leave both surviving as
separate, undetected duplicate heads. Resolve manually if you hit this;
teaching the merge about rekeys is real, separate work.
retired "OldAggregate", declared at the edge level rather than inside
an aggregate block, says an aggregate is simply gone — the honest
alternative to a was: claim pointing at something unrelated.
Everything above rests on one Postgres mechanism: the moment a new era
materializes, a row-level security policy on the journal admits inserts
to that era and no other — FORCE ROW LEVEL SECURITY, so even the
table’s owner is held to it. An old checkout that keeps running may
still boot and read its own era; it may not write it. That is the whole
guarantee, and it has one hole Postgres itself leaves open: a
superuser, or any role granted BYPASSRLS, is exempt from every
policy on every table, unconditionally. FORCE narrows the owner’s
exemption and nothing else. Connect the domain as such a role and the
fence is void — an old checkout writes its superseded era straight
through every mint, into a partition no newer head reads, and nothing
refuses.
That is exactly what a bare database "some_db" does on a self-hosted
machine: it connects as whatever Postgres user your shell defaults to,
which is almost always a superuser. So PostgresEra asks the catalog
first, on every boot, and refuses to boot over a superuser or
BYPASSRLS connection, naming the role and the two ways out:
database
"postgres://<role>@<host>/<db>". An ordinary role that owns the
database still provisions and mints (owner-only DDL), and is held to
the fence like everyone else. This is the real fix, and what a
deployment does; qa/bluebook/quality_control.world in this
repository is the worked example, with bin/qa_postgres_role as its
one-time operator step.allow_superuser true, in the same persisted_by block — boots
anyway, on the record, and says so on stderr on every boot. The
examples in this repository carry it because they run against your
local Postgres as you; this guide’s own Grange world above carries
it for the same reason.Even under the opt-in, one guard still stands: a checkout that booted a
superseded era knows it did, and refuses its own writes in-process,
before any INSERT — reads keep working, and the refusal names the era
that superseded it and tells you to pull and reboot. The database-level
fence is the one that holds against every process; that in-process
check is the one that holds when the fence cannot.
Change the bluebook. Run hecks era.scaffold_translation. Resolve every
unresolved line it leaves you — that is where the real decisions
live, not busywork to get through. Run hecks era.audit_translation and
read the sample; do not skim it. Then boot. If a compute or rekey
rule is involved, run the audit with --approve first, and mean it.
The domain never learns any of this happened. The bluebook you write
next describes the shape you have now, not the history that got you
here — the history lives in translations/, one file per edge, read
by the next person who needs to know why a field moved.