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
| Column | Type | Description | ||
|---|---|---|---|---|
type_name | TEXT UNIQUE | Snake-case identifier (e.g. "cites", "tagged_with") | ||
label | TEXT | Human-readable label | ||
mode | TEXT | "derived" \ | "asserted" \ | "inferable" |
directional | BOOL | Whether the edge has direction (default true) | ||
weighted | BOOL | Whether edges carry a confidence score (default false) | ||
type_pairs | JSON | Array of {source_type, target_type} restricting valid endpoints | ||
attributes | JSON | Arbitrary metadata |
smeldr_relations
| Column | Type | Description |
|---|---|---|
source_type, source_id | TEXT | The dependent item |
target_type, target_id | TEXT | The referenced item |
relation_kind | TEXT | References type_name in smeldr_relation_kinds |
edge_class | TEXT | "asserted" or "inferred" |
confidence | REAL | Optional, for weighted kinds |
valid_at | DATETIME | Edge is active at or after this time (null = always) |
invalid_at | DATETIME | Edge became stale at this time (null = still active) |
created_by_job | TEXT | Agent job ID that created the edge, if any |
attributes | JSON | Arbitrary 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
| Layer | Trigger | Latency | Coverage |
|---|---|---|---|
| Layer 1: save-path sync | Every content save | Synchronous | Source-side writes through Smeldr |
| Layer 2: AfterRelationCascade | Target item changes state | ~500ms debounce | Observed lifecycle events |
| Layer 3: structural sweep | Cron (e.g. hourly) | Periodic | Bulk 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.