Documentation
Guides

Content Relations

Content Relations

Content Relations gives Smeldr a typed edge graph: a persisted, queryable record of which content items reference which others, what kind of dependency each edge represents, and whether each dependency is structurally valid.

A relation replaces implicit references — a slug buried in a body field, a hard-coded ID in a template — with explicit structure that the framework actively keeps correct across three cooperating layers.


Schema

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

smeldr_relation_kinds

ColumnTypeDescription
type_nameTEXT UNIQUESnake-case identifier (e.g. "cites", "tagged_with")
labelTEXTHuman-readable label
modeTEXT"derived" \"asserted" \"inferable"
directionalBOOLWhether the edge has direction (default true)
weightedBOOLWhether edges carry a confidence score (default false)
type_pairsJSONArray of {source_type, target_type} restricting valid endpoints
attributesJSONArbitrary metadata

smeldr_relations

ColumnTypeDescription
source_type, source_idTEXTThe dependent item
target_type, target_idTEXTThe referenced item
relation_kindTEXTReferences type_name in smeldr_relation_kinds
edge_classTEXT"asserted" or "inferred"
confidenceREALOptional, for weighted kinds
valid_atDATETIMEEdge is active at or after this time (null = always)
invalid_atDATETIMEEdge became stale at this time (null = still active)
created_by_jobTEXTAgent job ID that created the edge, if any
attributesJSONArbitrary edge-level metadata

Boot-time setup

// Create tables once at startup
smeldr.CreateRelationTables(db)

// Create the store — hydrates the kind registry from the DB
store, err := smeldr.NewRelationStore(db)
if err != nil {
    log.Fatal(err)
}

// Wire to the app
app := smeldr.New(cfg).
    Relations(store)

App.Relations does three things at once: 1. Stores the RelationStore on the app. 2. Installs a SyncSaveHook that calls RecomputeAsserted after every content save — keeping the graph consistent with no background job (Layer 1). 3. Subscribes the cascade handler to AfterPublish, AfterArchive, AfterDelete, and AfterUnpublish — notifying dependents when a target changes state (Layer 2).

The structural sweep (Layer 3) is wired separately via smeldr.dev/agent. See SweepScheduler.


RelationKindDef

RelationKindDef is the Go struct for registering and updating relation kinds:

type RelationKindDef struct {
    TypeName    string          // required; unique snake_case identifier
    Label       string
    Mode        string          // "derived" | "asserted" | "inferable"
    Directional bool
    Weighted    bool
    TypePairs   json.RawMessage // [{source_type, target_type}, ...]
    Attributes  json.RawMessage
}

ValidateRelationKindDef checks that TypeName is non-empty, Mode is one of the three valid values, and TypePairs is valid JSON. Use it before UpsertKind when accepting kind definitions from external input.


edge_class: asserted vs. inferred

  • asserted — the edge was established by the application: a save-path hook,

an operator action, or an explicit Assert call. Asserted edges represent facts the application treats as authoritative.

  • inferred — the edge was proposed by an AI agent for human review (via the

propose_relation MCP tool). Inferred edges are visible in the graph but are not treated as authoritative until promoted to asserted.

Typical workflow: an agent discovers a relation and calls propose_relation. An editor reviews it and calls assert_relation to promote it — or deletes it if the proposal is wrong. Rules can also promote inferred edges automatically based on confidence threshold or other criteria.


Managing kinds

// Register or update a kind (idempotent — 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"}
    ]`),
})

// Read (in-memory registry lookup — no DB round-trip, no error)
kind, ok := store.GetKind("cites")

// List all registered kinds (sorted by type_name)
kinds := store.ListKinds()

Working with edges

Assert

Creates or updates an edge. Idempotent on (source_type, source_id, target_type, target_id, relation_kind):

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

GetBySource / GetByTarget

// All "cites" edges where postID is the source
edges, err := store.GetBySource(ctx, "post", postID, "cites")

// All edges where sourceID is the target, any kind ("who depends on this?")
dependents, err := store.GetByTarget(ctx, "source", sourceID, "")

An empty kind string returns all edges regardless of kind. GetByTarget with an empty kind is the "who depends on this?" query — the basis for impact preview before archiving or deleting an item.

Delete

err := store.Delete(ctx, edgeID)

Hard-deletes the edge immediately. To soft-invalidate (preserving history), see how the structural sweep sets invalid_at in SweepScheduler.

RecomputeAsserted and BulkRecompute

RecomputeAsserted performs a differential update for one item: reads existing asserted edges for that item, diffs against the incoming set, and applies only the delta. When the diff is empty — the common case — it returns after one SELECT and zero writes.

err := store.RecomputeAsserted(ctx, "post", postID, incomingEdges)

BulkRecompute runs the same operation for multiple items in a single transaction. Use it for imports or batch operations where calling RecomputeAsserted in a loop would be too slow.


Node.Rev and ErrRevConflict

Every content node carries a Rev field: a monotonically increasing integer incremented by the storage layer on every save after the first (Rev starts at 0 on insert).

// node.go
Rev int `db:"rev"`

If two goroutines read the same node at Rev = 3 and both attempt to save, the second write returns ErrRevConflict rather than silently overwriting the first. Use Rev as an optimistic-concurrency token when your code reads a node and then writes based on what it read.

This is relevant 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 correctness model

LayerTriggerLatencyCoverage
Layer 1: save-path syncEvery content saveSynchronousSource-side writes through Smeldr
Layer 2: AfterRelationCascadeTarget item changes state~500ms debounceObserved lifecycle events
Layer 3: structural sweepCron (e.g. hourly)PeriodicBulk imports, restores, missed events

No single layer is sufficient. Together they cover the full space: fast on the common path, reactive on observed events, and corrective on anything that slipped through.


AfterRelationCascade

When a target item's state changes, Smeldr fires AfterRelationCascade once per dependent source item. Register a handler via App.OnSignal:

app.OnSignal(smeldr.AfterRelationCascade, func(ctx context.Context, ev smeldr.SignalEvent) error {
    // ev.Type          — content type of the dependent item
    // ev.NodeID        — ID of the dependent item
    // ev.ActorID       — ID of the target item that changed state
    // ev.PreviousState — triggering signal (e.g. "after_archive")

    // Common actions: re-validate, queue a review job, update a derived field,
    // emit a notification, trigger an agent.
    return nil
})

The signal is debounced per source item with a 500ms window: if three targets change state in quick succession, the source receives one AfterRelationCascade after the window expires, not three.

Three guards prevent cascade storms:

  • Visited-set — each source item is notified at most once per handler call.
  • Idempotency-set — each edge is processed at most once per call.
  • Depth = 1 — transitive cascades are not triggered automatically. If A cites

B and B cites C, archiving C notifies B. A is notified only if B then actually changes state.