Why CI enforcement matters
- Generation-time hooks cover interactive and agentic Claude Code sessions
- Code can also arrive from manual edits, other tools, or agents without hook coverage
- CI enforcement is the backstop — every commit checked regardless of how it was produced
- Positions governance at three layers: generation time → MR review → CI gate
How it works
The pipeline job runs mneme check against the changed files in the merge request diff.
mneme checkvalidates a file or diff against the decision corpus in.mneme/project_memory.json- Run against changed files in a merge request to surface any architectural violations
- In strict mode, the default: exit code 0 = PASS → the job passes
- Exit code 1 = WARN and exit code 2 = FAIL or INCOMPLETE → the job fails. To block the merge, turn on “Pipelines must succeed” in the project’s merge request settings.
Example pipeline
# .gitlab-ci.yml
mneme-decisions:
stage: test
image: python:3.12 # includes git; the slim image doesn't
variables:
GIT_DEPTH: "0" # full history, so the merge request base is available
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- python -m pip install "mneme-hq==0.10.0"
# Fail if an edited ADR wasn't re-imported into the memory file
- mneme adr import docs/adr --memory .mneme/project_memory.json --apply --update-existing
- git diff --exit-code -- .mneme/project_memory.json
# Check each added or modified text file against its repository path
- |
worst=0
mkdir -p mneme-results
git diff --numstat --no-renames --diff-filter=AM "$CI_MERGE_REQUEST_DIFF_BASE_SHA...HEAD" > changes.txt || exit 2
tab=$(printf '\t')
while IFS="$tab" read -r added _ f; do
[ -n "$f" ] || continue
if [ "$added" = "-" ]; then echo "skip $f (binary)"; continue; fi
out="mneme-results/$(printf '%s' "$f" | tr '/' '_').json"
rc=0
mneme check --memory .mneme/project_memory.json \
--input "$f" --target-path "$f" \
--query "MR change: $f" --json > "$out" || rc=$?
echo "exit=$rc $f"
if [ "$rc" -gt "$worst" ]; then worst=$rc; fi
done < changes.txt
exit "$worst"
artifacts:
when: always
paths:
- mneme-results/
GitLab runs script lines with errexit on, so the loop records each exit code with || rc=$? instead of stopping at the first violation, and exits with the worst one. Binary files are skipped because mneme check reads text only. We ran this script under sh -e against a local copy of a merge request, not on GitLab runners. The two import lines assume ADRs in docs/adr; drop them if you don’t import ADRs. Check ADRs out with LF line endings (docs/adr/*.md text eol=lf in .gitattributes), because the memory file records a hash of each ADR’s bytes. The GitHub Actions how-to guide walks through the same loop with recorded output.
Status
- GitLab CI gates are part of the shipped foundation on the roadmap
- The
mneme checkcommand is available today viapip install "mneme-hq==0.10.0" - As of mneme-hq 0.10.0, Mneme doesn’t publish a GitLab CI template. You copy the job above into your
.gitlab-ci.ymland own it.
Layered governance model
Layer 1: Generation-time enforcement via Claude Code hooks
Layer 2: Merge request review (human + AI)
Layer 3: CI gate via Mneme in GitLab CI
What the gate actually checks
The job runs mneme check in strict mode, the default, on each changed file against the project’s decision corpus. Each file gets one of four verdicts:
- PASS: no enforced rule matched the file. Exit code 0.
- WARN: a constraint matched, for example a
FORBID_DEPENDENCYword. Exit code 1 in strict mode, which fails the job. With--mode warn, PASS, WARN and FAIL exit 0 and findings appear only in the job log; INCOMPLETE still exits 2. - FAIL: a typed rule or anti-pattern matched. Exit code 2; the job fails until the code or the decision corpus changes.
- INCOMPLETE: the evaluation couldn’t finish. Exit code 2, never reported as PASS.
Mneme has no override record and no expiry field. An exception is a change to the decision corpus in the same merge request: add a path to a FORBID_LITERAL rule’s exclude_paths (the structured form, alongside its include_paths), or retire the decision with a new ADR that lists it under supersedes (keep the old ADR file in docs/adr; the import rejects a supersedes reference to an ADR that isn’t there), then re-import with --apply --update-existing; the old decision is marked superseded and stops producing verdicts. Don’t edit project_memory.json by hand: from 0.10.0, a memory file whose decisions no longer match its decision index fails to load. Reviewers see the change in the MR diff, so weakening a decision is visible rather than a quiet bypass.
FAQ
Should we run this on every merge request or only on the default branch?
What if a merge request needs to override a decision intentionally?
FORBID_LITERAL rule’s exclude_paths, or retire the decision with a new ADR that lists it under supersedes (keep the old ADR file in docs/adr; the import rejects a supersedes reference to an ADR that isn’t there), then re-import with --apply --update-existing. Don’t edit project_memory.json by hand: from 0.10.0, a memory file whose decisions no longer match its decision index fails to load. The exception shows in the MR diff, so weakening a decision is visible rather than a silent merge.