Skip to content

Decision left no record

A situation that should have produced a record — a decision made, a memory worth keeping — produces nothing. Nobody notices the absence, because there is nothing there to notice. The record that should exist and the record that was never made look identical from the outside: empty. The cost lands later, when the reasoning behind a choice is needed and was never written down.

Watch it happen — then get refused

The trigger and the record are tied together: when a situation calls for a record, the turn must declare it and produce the artifact. Two gates enforce the two halves — that a record was declared at all, and that the declared record actually landed.

  1. A context wakes on the declaring tag or a touch under the record’s paths, and accumulates what showed up, so the gates can read it back at Stop.

  2. A structure file-guard holds the record files to their allowed layout, so a record can only land where it belongs.

  3. Two gates at Stop: one refuses when the situation called for a record and none was declared; the other refuses when a record was declared but the artifact never landed.

A context can only track; the gates are what refuse the turn.

This is the real example shipped at examples/completeness-artifact-on-trigger/. Three natures under .sloprail/ — each a *.yaml declaration plus the scripts it names:

  • Directory.sloprail/
    • Directorycontext/
      • Directorytag-declared/
        • context.yaml
        • enter.sh
        • exit.sh
    • Directoryfile-guard/
      • structure.yaml
    • Directorygate/
      • Directorytag-required/
        • gate.yaml
        • refuse.sh
      • Directoryverify-artifact-produced/
        • gate.yaml
        • verify-tag-and-artifact.sh

Wakes on the declaring tag or a touch under the record paths, and logs what appeared into its registry so both gates can read it at Stop.

.sloprail/context/tag-declared/context.yaml
# Wakes on either a #update/#decision/#skip tag or a file touch under the
# artifact paths. Each occurrence logs what showed up into the registry
# (sr-session state), so the paired Stop gate can read back which tag was
# declared and which artifacts landed — even across separate tool calls.
on:
- event: PostTagWrite
match: any(event.tags, .label == "update" or .label == "decision" or .label == "skip")
- event: PostFileCreate
match: event.path startsWith "memories/updates/" or event.path startsWith "memories/decisions/"
- event: PostFileUpdate
match: event.path startsWith "memories/updates/" or event.path startsWith "memories/decisions/"
enter: ./enter.sh
exit: ./exit.sh
.sloprail/context/tag-declared/enter.sh
#!/usr/bin/env bash
# enter: logs whichever fired (tag or artifact touch) into the registry
# (sr-session state) rather than into `payload`, so a tag and an artifact landing
# in different tool calls both get recorded.
set -uo pipefail
input="$(cat)"
kind="$(printf '%s' "$input" | jq -r '.event.kind // ""')"
case "$kind" in
PostTagWrite)
tags="$(printf '%s' "$input" | jq -r '.event.tags[]?.label // empty')"
while IFS= read -r tag; do
[ -z "$tag" ] && continue
sr-session state set "tag:$tag" "declared"
done <<< "$tags"
;;
PostFileCreate|PostFileUpdate)
path="$(printf '%s' "$input" | jq -r '.event.path // ""')"
[ -n "$path" ] && sr-session state set "artifact:$path" "$kind"
;;
esac
jq -n '{active_since: "trajectory"}'
.sloprail/context/tag-declared/exit.sh
#!/usr/bin/env bash
# exit: thin, reads the paired gate's verdict (verify-artifact-produced).
set -uo pipefail
input="$(cat)"
status="$(printf '%s' "$input" | jq -r '.gates["verify-artifact-produced"].status // "fail"' 2>/dev/null)"
if [ "$status" = "pass" ]; then
exit 0
fi
exit 1

A structure file-guard: an allow-list of the paths a record may occupy. Anything outside it is disallowed.

.sloprail/file-guard/structure.yaml
allow:
- glob: "memories/updates/*.md"
- regex: "^memories/decisions/[0-9]{8}_[a-z0-9-]+/.*\\.md$"

Two gates. One refuses the stop when no record was declared for a situation that needed one; the other refuses when a record was declared but its artifact never landed.

.sloprail/gate/tag-required/gate.yaml
# tag-declared activates only once a tag was written, so `not ...active` is
# exactly "no tag declared". No `require`: match reads the context's current
# state fresh each Stop, so nothing has to run first.
on:
- event: Stop
match: not context["tag-declared"].active
checks:
- script: ./refuse.sh
.sloprail/gate/tag-required/refuse.sh
#!/usr/bin/env bash
# match already confirmed no tag was declared — just refuse with the remedy.
echo "This turn declared no tag (#update, #decision, or #skip) — declare one before the turn can end." >&2
exit 1
.sloprail/gate/verify-artifact-produced/gate.yaml
# Runs only once tag-declared is active, reading the registry it accumulated.
on:
- event: Stop
match: context["tag-declared"].active
require:
- context: tag-declared
checks:
- script: ./verify-tag-and-artifact.sh
.sloprail/gate/verify-artifact-produced/verify-tag-and-artifact.sh
#!/usr/bin/env bash
# Reads tag-declared's registry: which tags were declared, and how many artifacts
# landed. #skip needs no artifact; any other tag must have a matching artifact.
# The no-tag-at-all case is the sibling tag-required gate's job, not this one's.
#
# `require: [{context: tag-declared}]` guarantees the context ran first, so the
# entries are current. `state list` emits JSON-LINES, so reads slurp with `jq -s`.
set -uo pipefail
entries="$(sr-session state list --owner tag-declared 2>/dev/null)"
tags="$(printf '%s' "$entries" | jq -s -r '[.[] | select(.key | startswith("tag:"))] | .[].key | ltrimstr("tag:")')"
artifacts="$(printf '%s' "$entries" | jq -s -r '[.[] | select(.key | startswith("artifact:"))] | length')"
if echo "$tags" | grep -qx "skip"; then
exit 0
fi
if [ "${artifacts:-0}" -eq 0 ]; then
echo "Turn declared a tag ($tags) but no matching artifact was produced this turn — the tag was stated, the artifact was not." >&2
exit 1
fi
exit 0