Unlinked page
The failure
Section titled “The failure”A person, a fact, a decision gets written down, but nothing else in the knowledge base ever points back to it. The file exists and it’s gone anyway — nothing reaches it, so nothing surfaces it when it’s needed. An entry nobody links to is effectively lost the moment it’s written, and the loss is silent because the file is right there, just unreachable.
How it works
Section titled “How it works”Every entity that changes has to end up linked from somewhere. This is completeness: the guardrail collects the entities touched this cycle and refuses the turn if any of them is left with nothing pointing to it.
-
A context wakes on each touch of an entity file and grows a record of the entities that changed this cycle — incrementally, not a full-repo scan.
-
A gate at
Stopruns only while that context is active, and refuses when a changed entity has nothing linking to it. Turns that touched no entity are skipped, not refused.
A context can only track; the gate is what refuses the orphan.
The actual configuration
Section titled “The actual configuration”This is the real example shipped at examples/interlinking/. Two natures, each a
directory under .sloprail/ — a *.yaml declaration plus the scripts it names:
Directory.sloprail/
Directorycontext/
Directorypeople-linked/
- context.yaml
- enter.sh
- exit.sh
Directorygate/
Directoryverify-linked/
- gate.yaml
- verify-changed-file-linked.sh
context
Section titled “context”Wakes on each entity touch and grows a record of what changed this cycle. It only tracks — it never refuses.
# Not a full-repo scan every cycle — incremental. One `enter`, fired on EVERY# people/*.md touch this cycle. Post, not Pre: Pre doesn't always fire.on: - event: PostFileCreate match: event.path startsWith "people/" and event.path endsWith ".md" - event: PostFileDelete match: event.path startsWith "people/" and event.path endsWith ".md"enter: ./enter.shexit: ./exit.sh#!/usr/bin/env bash# Fires per people/*.md touch. Logs into state, not `payload`, so a two-file# turn keeps both entries instead of the last firing overwriting the first.set -uo pipefail
input="$(cat)"path="$(printf '%s' "$input" | jq -r '.event.path // ""')"kind="$(printf '%s' "$input" | jq -r '.event.kind // ""')"
if [ -z "$path" ]; then exit 0fi
sr-session state set "$path" "$kind"
jq -n '{active_since: "trajectory"}'#!/usr/bin/env bash# exit: thin, reads the paired gate's verdict (verify-linked).set -uo pipefail
input="$(cat)"status="$(printf '%s' "$input" | jq -r '.gates["verify-linked"].status // "fail"' 2>/dev/null)"
if [ "$status" = "pass" ]; then exit 0fi
exit 1The piece that blocks. It runs at Stop only while the context is active, and
refuses when a changed entity is left unlinked.
# Checks the people the paired context logged this cycle, and must SKIP (not# refuse) turns that touched no people file. Both keys are needed: `match` is# the filter (fire only while the context is active), `require` is the ordering# guarantee (context entered before the check reads its registry). require alone# would refuse every people-free turn, since an unmet {context} refuses.on: - event: Stop match: context["people-linked"].activerequire: - context: people-linkedchecks: - script: ./verify-changed-file-linked.sh#!/usr/bin/env bash# For each CREATED person this cycle: at least one link from updates/decisions# must exist. For each DELETED person: no dangling links may remain. Reads the# full registry via `state list --owner people-linked` (the gate's require keeps# the entries current). Two gotchas:# - `state list` emits JSON-LINES, not an array — slurp with jq `-s`.# - greps are anchored on $SR_WORKSPACE (with `.` fallback) because cwd is this# guardrail's own folder, not the repo root.set -uo pipefail
ws="${SR_WORKSPACE:-.}"
entries="$(sr-session state list --owner people-linked 2>/dev/null)"
if [ -z "$entries" ] || [ "$(printf '%s' "$entries" | jq -s 'length')" -eq 0 ]; then exit 0fi
failures=""while IFS= read -r row; do [ -z "$row" ] && continue path="$(printf '%s' "$row" | jq -r '.key')" kind="$(printf '%s' "$row" | jq -r '.value')" name="$(basename "$path" .md)"
case "$kind" in PostFileCreate) if ! grep -rlq "$name" "$ws/updates" "$ws/decisions" 2>/dev/null; then failures="$failures $path(unlinked)" fi ;; PostFileDelete) if grep -rlq "$name" "$ws/updates" "$ws/decisions" 2>/dev/null; then failures="$failures $path(still-referenced)" fi ;; esacdone < <(printf '%s' "$entries" | jq -s -c '.[]')
if [ -n "$failures" ]; then echo "Interlinking check failed for:$failures" >&2 exit 1fi
exit 0