Lesson 04 — Slots and bindings at the data level
Goal: by the end of this lesson you can name the four entities that make up graphden's slot/binding model and explain why they're separate.
Concepts introduced: slot, fn-slot, binding,
binding-list-item, slot identity, rename-view slots
(slot.source-slot-id), type-override.
Why four entities instead of one
If a "slot binding" were ONE row carrying (fn-id, slot-name, value), graphden couldn't:
- Let two fn-defs share a slot identity. Inheritance would have to walk by NAME, which breaks when ancestors rename.
- Express a value that lives in TWO places (parent binds it, child overrides it).
- Express sequence bindings like
:list-appendcleanly.
So the data model splits the concern across four entities (plus one
self-referencing field on slot for renames):
| Entity | What it is | Lives across branches? |
|---|---|---|
slot |
An atomic (name, type-fn-id) pair. Immutable. |
Identity row, shared across branches |
fn-slot |
Junction: "fn F exposes slot S at position P" |
Per-branch version rows |
binding |
Per-(fn, slot) customization: value, ref-fn-id, type override, etc. |
Per-branch version rows |
binding-list-item |
Sequence content under a list-typed binding | Per-branch version rows |
slot.source-slot-id |
When set, this slot is a RENAMED view of another | Identity (immutable) |
The fn row joins them all.
Walking through one fn
{:name :add-10
:parent :add
:args {:nums [10]}}
What's actually in the DB after sync?
fn: {id: add-10-id, name: "add-10", parent-ids: [add-id]}
slot: {id: add.nums-slot-id, name: "nums", type-fn-id: sequence}
fn-slot: {fn-id: add-id, slot-id: add.nums-slot-id, position: 0}
binding: {fn-id: add-10-id, slot-id: add.nums-slot-id,
value: nil, ref-fn-id: nil,
list-append: true, list-closed: nil}
binding-list-item: {binding-id: ..., position: 0, value: 10}
Three load-bearing things here:
- The slot is owned by
:add.:add-10doesn't get its own slot row — it INHERITS via:parent-idsBFS. - The binding lives on
:add-10, attaching to:add's slot. Thelist-append: trueflag says "extend the inherited sequence, don't replace it" — Lesson 06 is about that. - The binding-list-item carries the actual literal
10.
When does a fn own its OWN slot?
Only when it adds a NEW arg name not seen in its ancestors. Base functions declare their slots — that's how they introduce new vocabulary. Composed fn-defs that just bind their parent's slots don't add new ones.
The exception is renames (next section).
Renames — when the name has to change
Say you have:
{:name :str-len
:args {:string {:type :text}}}
…and you want a descendant :hello-len that runs
str-len but calls its arg :input (because callers shouldn't
see the underlying name):
{:name :hello-len
:parent :str-len
:args {:string {:as :input}}}
{:as :input} is a rename. It writes a NEW slot row owned by
:hello-len, whose :source-slot-id points back at
:str-len.:string's slot id. Descendants of :hello-len see the
slot under the name :input; binding it goes to the new slot id,
but the resolver walks source-slot-id to find the original
type, default binding, etc.
This is why slot has an FK to itself — renames form a chain.
Type overrides — narrowing without renaming
A binding can override the inherited type:
{:name :positive-len
:parent :str-len
:args {:string {:type :non-blank-text}}}
{:type :non-blank-text} writes :type-override-fn-id on the
binding, pointing at :non-blank-text's row. The slot itself
stays at :text, but THIS fn (and its descendants) sees the
narrower type. Useful for asserting a contract at a chain hop
without forcing a rename.
Constraint: descendant overrides can only NARROW (subtype of
the inherited type) — "you can't promise less than your parent
did." A widening is a type ERROR, but it does not block the
save: the write succeeds and the failure is recorded as a
per-branch type diagnostic. The fn's card gets a ⚠ badge, the
fn shows up under the Explorer's ⚠ type errors filter (its row
marked ⚠1, the message under the argument in the Inspector's
Bindings tab), and trying to EXECUTE it is refused until you fix
the type. (Structural
violations — cycles, name collisions — and secret-flow
violations still reject the save outright.)
Try it
Prefer to be shown? This lesson exists as a guided in-editor tour: open the demo with the tour running (no sign-up), or pick “Interactive tutorial” in the editor's account menu.
One slot, many bindings — prove it with two children of one fn:
- Type
str-upperin the Explorer filter and click thestr-upperrow (core.strings). It declares one slot,:string; its type chip saystext. - Click
⋯on thestr-upperrow, choose Extend, name ittutorial-a, then Save. The:stringrow on the new card comes fromstr-upper: the slot is INHERITED, not copied. - Click the
+on:string, choose Bind literal, typealpha, then Save. Run it (⋯→ ▶ Run → Run):ALPHA—str-upper's implementation, over the binding that lives ontutorial-a. - Select
str-upperagain (filter for it). Its:stringis still unbound — your binding lives ontutorial-a, not on the slot. - ⋯ → Extend again, name this one
tutorial-b, Save. Same slot identity astutorial-a's, and an empty+again, becausetutorial-bhas no binding of its own yet. - Click the
+on:string, Bind literal, typebeta, Save. Run it:BETA. - A bound literal is a click away from a new one: click the value
betaon the canvas, typegamma, Save — the binding row was updated, not re-created. - Click
gammaonce more and press Delete (confirm). The binding is gone and the+is back on:string: the slot is free again, andtutorial-bruns likestr-upperdoes. Slipped? Ctrl+Z (⌘Z) brings the value back for 30 seconds.
For a fn bound into a slot the same two verbs live on that card's
⋯: ✎ Change value (pick another fn, or a literal) and ×
Remove this value (the slot reverts to a free arg).
tutorial-a says ALPHA, tutorial-b is free again, str-upper
never changed — one slot identity, three independent binding states,
and a value you can change or take away without touching anyone
else. That separation is why inheritance never copies anything.
Going further
A rename and a type override on the same slot, in one fn-def (both are ordinary editor moves too — click the arg name on the edge to rename it, lesson 05, and click the type chip to override the type, lesson 08):
{:name :tutorial-renamed-input
:parent :str-len
:args {:string {:as :input :type :non-blank-text}}}
Open its card: the arg row now says :input (renamed) and its
type chip says non-blank-text (narrowed). The provenance ↳ badge
shows where each came from — :input from
:tutorial-renamed-input's slot, :non-blank-text from the
type-override binding. Clicking the type chip in the editor offers
only types that NARROW :text — :any isn't listed (the
compatible-type select is described in Lesson 08).
To see a type diagnostic land the way it actually happens — with a
value — extend :http-server as :tutorial-bad-port and bind its
:port (type port, a refined :int, 1..65535) to -1. The live
status flips to ✗, but Save still LANDS: the card root row gains a
⚠ badge, the Explorer's ⚠ type errors filter counts it, the
Inspector's Bindings tab shows the diagnostic under port, and
▶ Run is REFUSED with "unresolved type errors". Bind :port
to 8081 and the fixing save clears all of it.
What we glossed over
- Free arguments — when no ancestor binds a slot, it becomes a "free arg" the caller must supply. Lesson 05.
- The
:fnslot type — slots can also expect callables, and those flow differently. Lesson 09. - Per-branch evolution of bindings — how a binding's
valuelives on abinding-versionrow scoped to a branch. Lesson 23.