Skip to content

Unlinked page

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.

Watch it happen — then get refused

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.

  1. 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.

  2. A gate at Stop runs 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.

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

Wakes on each entity touch and grows a record of what changed this cycle. It only tracks — it never refuses.

.sloprail/context/people-linked/context.yaml
# 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.sh
exit: ./exit.sh
.sloprail/context/people-linked/enter.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 0
fi
sr-session state set "$path" "$kind"
jq -n '{active_since: "trajectory"}'
.sloprail/context/people-linked/exit.sh
#!/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 0
fi
exit 1

The piece that blocks. It runs at Stop only while the context is active, and refuses when a changed entity is left unlinked.

.sloprail/gate/verify-linked/gate.yaml
# 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"].active
require:
- context: people-linked
checks:
- script: ./verify-changed-file-linked.sh
.sloprail/gate/verify-linked/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 0
fi
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
;;
esac
done < <(printf '%s' "$entries" | jq -s -c '.[]')
if [ -n "$failures" ]; then
echo "Interlinking check failed for:$failures" >&2
exit 1
fi
exit 0