Content Relations, Layer 2: AfterRelationCascade

When a referenced item is archived, everything that depends on it needs to know. AfterRelationCascade is the signal that carries that notification, fired once per dependent item and debounced 500ms, with three guards that prevent cascade storms. Here is exactly what happens when a target changes state.

Content Relations, Layer 2: AfterRelationCascade

The save-path sync from the first post in this series keeps the relation graph consistent when a source item is saved. But what happens when a target item changes? If a source that a hundred blog posts cite is archived, none of those blog posts were just saved. Their save hooks will not fire. The graph will correctly record that the edges exist, but the dependents have no way of knowing their referenced item just changed state.

Layer 2 answers this: the AfterRelationCascade signal.

What triggers it

When App.Relations is called, it subscribes a handler to four signals: AfterPublish, AfterArchive, AfterDelete, and AfterUnpublish. These are the signals that mean a target item's lifecycle state changed. Whenever one of them fires, the handler looks up all source-side items that have an edge pointing at the target via GetByTarget:

// from buildCascadeHandler in relations.go
return func(ctx context.Context, ev SignalEvent) error {
    edges, err := store.GetByTarget(ctx, ev.Type, ev.NodeID, "")
    if err != nil {
        return err
    }
    if len(edges) == 0 {
        return nil
    }
    // ... fire AfterRelationCascade for each unique source
}

ev.NodeID is the ID of the item that just changed state. GetByTarget returns all edges where that item is the target, that is, all items that depend on it. For each unique source item in that set, the handler fires AfterRelationCascade.

Three guards

A naive implementation would fire a cascade for every edge. Three guards prevent this from becoming a problem.

Visited-set. Within a single handler invocation, each (sourceType, sourceID) pair is notified at most once. If a blog post cites the same source via five different relation kinds, it still receives one AfterRelationCascade signal.

Idempotency-set. Each (edge.ID, previousState) pair is processed at most once per handler call. This protects against duplicate edges arriving via the same query.

Depth = 1. The handler subscribes to status-change signals (AfterPublish, AfterArchive, etc.), not to AfterRelationCascade itself. Transitive cascades are not triggered automatically. If post A cites source B, and source B cites document C, archiving C notifies B. It does not automatically notify A. A is notified when B changes state, but that requires B to actually change state, not just receive a signal.

This is a deliberate design choice. Transitive cascade storms are a known failure mode in reactive systems. Depth 1 keeps the behaviour local and predictable. An application that needs transitive notification can handle AfterRelationCascade on B and re-emit signals as needed.

Debounce

Multiple edges can point from the same source to different targets. If three targets change state in rapid succession, the source would otherwise receive three AfterRelationCascade signals within milliseconds.

The handler debounces per source item using a sync.Map of per-source debouncers with a 500ms window:

key := visitKey // "sourceType:sourceID"
d, _ := debouncers.LoadOrStore(key, newDebouncer(500*time.Millisecond, func() {
    debouncers.Delete(key)
    app.emitSignal(baseCtx, AfterRelationCascade, cascadeEv)
}))
d.(*debouncer).Trigger()

A LoadOrStore is safe here: the map stores the first debouncer for a given source key, and subsequent calls to Trigger on the same debouncer reset its timer. After the window expires, the debouncer fires, deletes itself from the map, and emits exactly one AfterRelationCascade for that source item. No matter how many targets change state simultaneously, each source item receives at most one cascade signal per 500ms window.

Handling the signal

Register a handler via App.OnSignal:

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

    log.Printf("item %s/%s depends on %s, which just fired %s",
        ev.Type, ev.NodeID, ev.ActorID, ev.PreviousState)

    // Common actions here: re-validate, queue a review job, update
    // a derived field, or emit a notification to an editor.
    return nil
})

ev.PreviousState carries the name of the triggering signal as a string, so the handler can distinguish between a target that was archived and one that was deleted. ev.ActorID carries the ID of the target item that changed. The handler does not receive the target item itself; if the handler needs it, it must look it up explicitly.

What this enables

The most direct use is impact preview before a destructive action. An editor is about to archive a source item. Before doing so, the application calls store.GetByTarget(ctx, "source", sourceID, ""), which returns all source-side dependents without firing any signals — the same data the preview_impact MCP tool exposes to agents and operators. The editor sees "12 items depend on this" and can decide whether to proceed.

When the archive action happens, AfterRelationCascade fires for each of those 12 items. Handlers can queue a review job, flag the item in the editor's dashboard, or re-run validation. The framework delivers the notification; what the application does with it is up to the application.

The third post in this series covers what happens when Layer 2 is not enough: missed events from bulk imports and direct database writes, and how the periodic structural sweep closes that gap.


Reference: Content Relations · MCP tools: Content Relations