Content Relations, Layer 1: the typed edge graph

Content items have always referenced each other. A blog post cites a source, a product page links a spec. Smeldr now tracks those references as a typed edge graph: persisted, queryable, and maintained correct over time by three cooperating layers. Here is how the graph is structured and why three layers are necessary.

Content Relations, Layer 1: the typed edge graph

Every content system eventually has to deal with the fact that items reference each other. A blog post cites a research paper. A product page references a specification. A doc page links to an API reference. These connections exist, but are usually invisible. They live as raw strings inside a body field, or as a hard-coded ID in a template, or in nobody's code at all because the developer knew which item pointed where and trusted that knowledge to stay true.

Content Relations makes those references explicit. Smeldr now maintains a typed edge graph: a live data structure that the framework actively keeps correct. It records which items depend on which others, what kind of dependency each is, and whether each dependency remains structurally valid.

The schema

Two tables back the graph. smeldr_relation_kinds is a registry of named edge categories. smeldr_relations is the edge table proper.

CREATE TABLE smeldr_relation_kinds (
    id            TEXT NOT NULL PRIMARY KEY,
    type_name     TEXT NOT NULL UNIQUE,
    label         TEXT NOT NULL DEFAULT '',
    mode          TEXT NOT NULL,          -- "derived" | "asserted" | "inferable"
    directional   INTEGER NOT NULL DEFAULT 1,
    weighted      INTEGER NOT NULL DEFAULT 0,
    type_pairs    TEXT NOT NULL DEFAULT '[]',
    attributes    TEXT NOT NULL DEFAULT '{}',
    created_at    DATETIME NOT NULL,
    updated_at    DATETIME NOT NULL
);

CREATE TABLE smeldr_relations (
    id              TEXT NOT NULL PRIMARY KEY,
    source_type     TEXT NOT NULL,
    source_id       TEXT NOT NULL,
    target_type     TEXT NOT NULL,
    target_id       TEXT NOT NULL,
    relation_kind   TEXT NOT NULL,
    edge_class      TEXT NOT NULL,        -- "asserted" | "inferred"
    confidence      REAL,
    valid_at        DATETIME,
    invalid_at      DATETIME,
    created_by_job  TEXT,
    attributes      TEXT NOT NULL DEFAULT '{}',
    created_at      DATETIME NOT NULL,
    updated_at      DATETIME NOT NULL
);

A relation kind defines the category of edge: its mode, whether it is directional, and which source/target type pairs are valid. An edge (RelationEdge) is a single typed adjacency between two content items. The edge_class field distinguishes between edges that an operator or save-path hook asserted ("asserted") and edges that an AI agent proposed for review ("inferred"). A confidence score and temporal bounds (valid_at, invalid_at) are optional; the sweep uses invalid_at to mark stale edges without deleting them.

Wiring

RelationStore wraps the database and an in-memory RelationKindRegistry that is hydrated at startup so kind lookups never hit the database:

store, err := smeldr.NewRelationStore(db)
if err != nil {
    log.Fatal(err)
}

app := smeldr.New(cfg).
    Relations(store)

App.Relations does three things at once: it stores the RelationStore, it installs a SyncSaveHook that recomputes asserted edges every time a content item is saved (Layer 1), and it subscribes the cascade handler to AfterPublish, AfterArchive, AfterDelete, and AfterUnpublish (Layer 2). The structural sweep (Layer 3) is wired separately via the agent module and covered in the third post in this series.

Registering a kind and asserting an edge

Kinds are registered via UpsertKind, which is idempotent and safe to call on every boot:

err := store.UpsertKind(ctx, smeldr.RelationKindDef{
    TypeName:    "cites",
    Label:       "Cites",
    Mode:        "asserted",
    Directional: true,
    TypePairs: json.RawMessage(`[
        {"source_type": "post", "target_type": "source"}
    ]`),
})

Once the kind is registered, edges can be asserted programmatically:

err := store.Assert(ctx, smeldr.RelationEdge{
    SourceType:   "post",
    SourceID:     postID,
    TargetType:   "source",
    TargetID:     sourceID,
    RelationKind: "cites",
    EdgeClass:    "asserted",
})

Or queried by source, target, or both:

edges, err := store.GetBySource(ctx, "post", postID, "cites")
dependents, err := store.GetByTarget(ctx, "source", sourceID, "")

GetByTarget with an empty kind string returns all edges that point at a given item, regardless of relation kind. This is the "who depends on this?" query and the basis for impact preview.

Layer 1: save-path sync

When App.Relations is called, a SyncSaveHook is installed. After every successful save of a runtime-defined content type, the hook calls RecomputeAsserted with the current set of relation fields extracted from the item's data. RecomputeAsserted performs a differential update: it reads the existing asserted rows, diffs against the incoming set, and applies only the delta. The diff key is (target_type, target_id, relation_kind). When the diff is empty, the common case, the function returns after one SELECT and zero writes.

This is the fastest layer. It runs synchronously on the save path and keeps the graph consistent with no background job required. It only covers runtime-defined content types with a schema field marked relation: edge. Compiled content types (via Module) can call store.RecomputeAsserted directly.

Node.Rev and optimistic concurrency

Content Relations also introduced Node.Rev, a monotonically increasing integer on every content node. The storage layer increments it on every save after the first (Rev starts at 0 on insert). If two goroutines read the same node and both attempt to save, the second write returns ErrRevConflict rather than silently overwriting:

// Rev is incremented by the storage layer on every save after the first.
// Use it as an optimistic-concurrency token: if two goroutines read the same
// node (both see Rev = 3), the second Save returns ErrRevConflict instead
// of silently overwriting.
Rev int `db:"rev"`

This matters for the relation graph: an edge assertion that races with a delete of the target item is now detectable rather than silent.

The three-layer model

Layer 1 handles the common case: save an item, its relations are updated synchronously. But it cannot react to what happens to the target. If a referenced source item is archived, the save-path hook for the source item will not fire. That is Layer 2's job: reacting to target-side state changes with a cascade signal. And neither Layer 1 nor Layer 2 can catch everything. A bulk import, a direct database write, or a missed signal can leave stale edges in the graph. That is Layer 3's job: the periodic structural sweep. The next two posts in this series cover each of those layers in detail.


Reference: Content Relations