Skip to content

Lock File

ai-rulez.lock (in the configuration directory, committed) is the record of exactly what your AI configuration is made of. It pins three things:

  1. Remote sources: every git include and installed skill, by commit and tree digest (as before).
  2. Authored content: a sha256 digest of every rule, context file, skill (with its resources), agent, command, hook and role, with its id, domain, owner and version when the frontmatter has them.
  3. Generated outputs: a digest of each generated file, so a change to what agents are actually told shows up in review even when nobody touched a source.

One file, one tree digest over all of it, under one hashing scheme for every kind (authored items, outputs, remote includes, installed skills, skill sources, OKF includes and served skills). Lock version = 1, or version = 2 when the lock holds role output pins ([roles] pin = true); lock writes 2 only then, so a lock without role pins stays readable by older builds. A lock with any other version is refused with an instruction to run ai-rulez lock again. A build that predates version 2 refuses a version 2 lock the same way, rather than reading a role pin as a default output with no path; do not run ai-rulez lock with that older build, it would write the lock without the role pins.

Threat model

Skills, rules and hooks are instructions an agent follows and, for hooks and skill scripts, code it runs. That makes them a supply chain:

  • a remote include or skill can move (a branch is force-pushed, a maintainer account is compromised);
  • a skill resource (a script under scripts/, a file under assets/) can change without its SKILL.md changing, and a SKILL.md diff may be a small part of a large pull request;
  • a merge or a dependency bump can alter the rendered output in a way nobody read.

What the lock gives you:

It detects How
A remote source serving different bytes than you reviewed commit and tree digest
An added, removed or edited rule / skill / resource / hook / role per-item digest, named in the check output
An executable bit added to a script the file mode is part of the digest
A change in what gets generated, by any cause output digests
An accidentally edited or truncated lock the tree digest no longer matches the pins, or is missing
A lock replaced by one without content pins (a downgrade) lock --check fails and generate --locked warns; under enforce, generate --locked and validate fail

The tree digest is an integrity check, not a signature: whoever can edit the lock can recompute it (ai-rulez lock does exactly that). It catches accidental edits and merge mistakes; a deliberate change to the pins is caught by review of the lock diff and by CI running lock --check against the sources, not by the digest.

What it does not do: it does not say who published a change, it does not sandbox anything, and it cannot tell a malicious edit from a good one. It makes every change explicit and reviewable; a human still reviews it (see Reviewing lock diffs). Pair it with ai-rulez scan / validate for the content itself. ai-rulez does not sign or verify signatures itself; Signing the lock shows how to do it with cosign.

What is pinned

kind id Pinned files
rule, context, agent, command, check the item name the source file (a command with resources also pins them); for an agent, skill or command whose frontmatter declares hooks, also each project script those hooks run (a word starting with ./, ../ or $CLAUDE_PROJECT_DIR/)
skill the skill directory name SKILL.md, every loaded resource (references/, scripts/, assets/) and the project scripts its frontmatter hooks run
local-include the include name the content directories (rules, context, skills, agents, commands, checks, domains, verifiers) of an include whose source is a local path; an OKF include is pinned whole
hook <event>:<matcher or *>:<n> the [[hooks]] group as declared and each script file
role the role name the [[roles]] entry as declared
settings permissions, claude-managed, mcp-servers, verifiers-settings the [permissions], [claude.settings.managed], [[mcp_servers]] and [verifiers_settings] sources (MCP servers as written, placeholders unresolved)
verifier the verifier id (name of a flat or inline entry) the declaration as written: a [[verifiers]] entry of config.toml, or one table of .ai-rulez/verifiers/*.toml (path names the file). Lowering a severity, widening an exclude or deleting a verifier changes the pin. Verifiers imported through an include are covered by the include's own pin
rubric the rubric directory name every file under .ai-rulez/rubrics/<id>/ (rubric.toml, system.md, golden cases and fixtures, calibration.json); path is rubrics/<id>. Editing a rubric, its prompt or its labels, or re-calibrating, changes the pin (Review). A symlink in the directory is a lock problem

Declared configuration that is not pinned at the source: profiles, include configuration, scoped (monorepo) configuration, plugin and marketplace authoring, and the machine-local overlay. A change there is caught only through the output pins, so with include_outputs = false (or scope = "skills") it is not covered. Keep output pins on when you rely on the lock for these.

Content from remote includes and built-in packs is not listed item by item: includes are pinned by their own digest, built-ins by the ai-rulez version. A local-path include is pinned as one local-include item over its content directories, wherever it lives (inside the repository or outside it). A missing path cannot be pinned: lock warns and lock --check fails until it is fixed. A symlink inside the pinned tree is never followed: it is pinned as its own entry, by link target (see below), and the loader does not read it. A local_override path is a development shortcut and is not pinned.

The lock file itself is never followed through a symlink. lock replaces a symlinked ai-rulez.lock with a regular file on write (it never writes through the link), and every command that only reads the lock refuses a symlinked one with an error, so a link cannot point the pins at another file. Delete the link and run ai-rulez lock to recreate it.

Outputs are pinned from the in-memory rendering, before the hash lines (Content-Hash, and Source-Hash with hashes = "full") are injected and with the Generated: stamp removed, so the digests are the same under every [header] hashes mode and whether or not [header] timestamp is on. Not pinned: machine-local outputs, outputs that may carry resolved secrets, and documents that are partly yours, that is, a merged document in which the consumer owns some keys. A plain .claude/settings.json that only ai-rulez writes is pinned like any other output. When the document is partly yours it is not pinned as a whole; its sources (the hooks, permissions, managed settings, MCP servers and roles above) are.

version = 1
ai_rulez_version = "5.0.0"
scope = "all"
outputs_pinned = true
tree = "sha256:6cd1d810fce0e91263b3ebfa3610821a820a07b415a8f2a4b9415de191b6c24e"

[[include]]
name = "shared"
source = "https://github.com/acme/ai-rules"
ref = "main"
commit = "0f3e…"
digest = "sha256:…"

[[item]]
kind = "skill"
id = "deploy"
domain = "backend"
path = "domains/backend/skills/deploy"
digest = "sha256:262d721306783b4c3b554a1345253c266f6f991733dad6a347e1b55d5e57ac05"
owner = "platform"
version = "1.2.0"

[[output]]
path = ".claude/skills/deploy/SKILL.md"
digest = "sha256:…"

Scan records

lock also writes one [[scan]] record per staged egress = false external scanner that has a cached result for the current content (scan --external fills the cache; lock starts no program):

[[scan]]
scanner = "cisco-skill-scanner"
version = "skill-scanner 1.2.0"
tree = "sha256:…"          # digest of the content staged for the scanner
findings = 2               # before the scanner baseline
max_severity = "warning"
result = "pass"            # fail when a finding reaches [lint.scanner_policy] fail_on (error by default)

A reviewer sees what was scanned at lock time and with what outcome. The records are outside the tree digest, like approvals: they do not make lock --check fail, and a scanner with no cached result for the content has no record (lock logs it). See External scanners.

The file is written deterministically: entries sorted, no timestamps, nothing that depends on map order or on the machine, as far as the content is: the source recorded for a skill that comes from an [[includes]] entry is include:<name>/<path inside the include>, never a path in your home directory or cache. A lock written before this recorded an absolute path for such skills; lock --check compares digests, not that field, so it still passes, and the next ai-rulez lock rewrites the entry once. Digests are the same on every operating system when the repository records the executable bit (see File modes); a script that is executable on disk but not recorded in git digests differently on Windows.

Served skills and skill sources

Served skill files are always digested at mode 100644: the executable bit of a served file is not part of its digest and is not preserved over MCP (unlike a tracked script, see File modes).

The same file carries the entries of dynamic skill loading, so one lock pins everything and one lock --check verifies everything:

[[source]]            # one per [[skill_sources]] entry: the commit its ref resolved to and the tree digest
name = "vendor"
source = "https://github.com/acme/skills"
ref = "v1.2.0"
path = "skills"
commit = "0f3e…"
digest = "sha256:…"

[[served]]            # one per skill the skills server serves, in the default view and in every other view
name = "deploy"
source = ".ai-rulez/skills/deploy/SKILL.md"
commit = ""
digest = "sha256:…"   # the served-skill digest below

[[served]]            # the same skill in another view: `view` is only written for a view other than the default
name = "deploy"
view = "role:backend"
source = ".ai-rulez/skills/deploy/SKILL.md"
commit = ""
digest = "sha256:…"

A served entry is keyed by name and view. The view is the way the skills server is started: role:<name> for --role, profile:<name> for --profile, targets:<preset> for --targets, static for --include-static, and source:<name> for each --source, joined by + (role:backend+static). The default view has no view key, so a project that uses no roles or view flags writes the same lock as before. A plain ai-rulez lock pins the default view, every role and every view the lock already records; lock --role, --profile, --targets, --include-static and --source add the view they name. The server and lock --check read the pins of the view they run with. A pin without a view also covers every view (locks written before views existed), but its digest must still match. lock --refuse-findings fails (exit 2, nothing written) when the security scan refuses a served skill; without it the skill is left unpinned, the rest is pinned and lock exits 3. A plugin source with no preset (a [plugin] configuration such as a marketplace member, whose skills ship in the bundles generate --plugin writes) has nothing to serve: lock pins its content and no served skills, and lock -r locks it alongside the marketplace root.

A served skill is a tree digest in the scheme below with the kind served-skill (ai-rulez/served-skill/v1), over the files the server returns, with the lines of the generated header that change without the skill changing left out (the project-wide Source-Hash, the per-file Content-Hash, which hashes raw bytes and so moves with a CRLF-only edit that the digest's text normalisation ignores, and the Generated: stamp; comment lines in the first 40 lines only). It cannot collide with the digest of the authored skill of the same name. There is one implementation (contentlock.ServedDigest); the skills server, lock, lock --check/--diff and [lock] enforce all use it, and the per-file and whole-skill digests the server reports (digest) are the same scheme over the bytes as served. A fetched source tree, a remote include, an OKF include and an installed skill are digested with the same scheme (contentlock.DigestDir: the regular files below the directory as one tree, kinds include, okf-include, installed-skill and skill-source, with .git and the root .cache_meta.json bookkeeping left out; files are streamed, not read whole); all entries are covered by tree. A symlink in such a tree is never followed and no longer an error: it is a leaf of its own, sha256(lp("ai-rulez/symlink/v1") || lp(path) || lp(target)) with the link target string from readlink (/-separated), so retargeting the link changes the digest while what it points at is not read. A non-regular entry that is not a symlink (a Windows junction, a socket, a device) is the leaf sha256(lp("ai-rulez/irregular/v1") || lp(path)) and is never read. Trees without such entries digest exactly as before. The tree digest also covers the source, ref and path of every remote entry and the serve view of a served entry, so editing or swapping them is detected even when the content digests do not change.

lock --check and lock --diff compare these entries without the network: a changed served skill is a served change, a source whose cached tree no longer matches its pin is a remote change. [lock] enforce = true makes validate report served-skill mismatches as AR995 and makes the server refuse them. lock --kind source|served refreshes one kind, generate --frozen/mcp --serve-skills --frozen never use the network, and lock --content-only recomputes authored content and the served digests of local skills offline while keeping the remote pins.

Version constraints

A remote include, installed skill or skill source can ask for a range of versions instead of a ref. ai-rulez resolves the range against the repository's semantic-version git tags, records the exact tag, its commit and the tree digest in the lock, and never moves the pin on its own.

[[includes]]
name = "shared"
source = "https://github.com/example-org/ai-rules"
version = "^1.2"                 # npm-style constraint
tag_prefix = "v"                 # optional; default accepts "1.2.3" and "v1.2.3"
include_prerelease = false       # optional; default false
min_release_age = "7d"           # optional; hold back tags younger than this (see below)

[[installed_skills]]
name = "deploy"
source = "https://github.com/example-org/skills"
version = "~2.1.0"
tag_prefix = "deploy/v"          # monorepo tags such as deploy/v2.1.3

[[skill_sources]]
name = "acme"
url = "https://github.com/example-org/skills"
version = ">=1.4.0 <2.0.0"
  • Syntax. ^1.2, ~2.1.0, >=1.4.0 <2.0.0 (a space is AND), 1.x, 1.2.*, 1 - 2, 1.x || 3, *. ^0.2.3 stays below 0.3.0 and ^0.0.3 below 0.0.4, as in npm. The parser is part of ai-rulez (internal/semver), with a conformance table in its tests.
  • ref shorthand. ref = "^1.2" means version = "^1.2" when the value contains ^, ~, * or a space, because git forbids those characters in ref names. ref = "1.2" or ref = ">=1" stay plain refs (legal branch names). Set ref or version, not both (AR731). A plain ref, a branch or a full commit SHA behaves exactly as before.
  • Which tags count. A tag is a version tag when it is a plain version or one after a single v, or, with tag_prefix, when it starts with the prefix and the rest is a plain version. Other tags are ignored. Annotated and lightweight tags are both accepted; an annotated tag is peeled to its commit. The highest tag the constraint allows wins; 1.2.3 and v1.2.3 naming one version is reported and the tag that sorts first is used.
  • Prereleases follow npm: 1.3.0-rc.1 is skipped unless the constraint names a prerelease of the same major.minor.patch (^1.3.0-rc.1) or include_prerelease = true. Build metadata is ignored.
  • Nothing resolves a range implicitly. With a lock, generate fetches the pinned commit and never resolves a range. Without a lock covering the source, generate resolves the range once (as it resolves a branch), so pin it with ai-rulez lock. generate --locked/--frozen fail on a range the lock does not cover. ai-rulez lock resolves a range for a source the lock does not cover and keeps a pin that still satisfies its constraint (no silent upgrade). ai-rulez update is the only command that moves a range pin.

The lock records the constraint as the requested ref plus what it resolved to; a lock entry written by an older ai-rulez has no tag and is simply not covered:

[[include]]
name = "shared"
source = "https://github.com/example-org/ai-rules"
ref = "^1.2"                     # the constraint, as written
tag = "v1.2.4"                   # the resolved tag
tag_object = "7a9c..."           # the annotated tag object id; absent for a lightweight tag
commit = "0f3e..."               # the peeled commit
digest = "sha256:..."            # tree digest, unchanged scheme
released = "2026-09-28T10:00:00Z"  # only with min_release_age: when the tag was released
released_from = "forge"          # where that time came from: forge, first-seen or commit

The pin covers the config while source, path and ref match and the recorded tag still satisfies the constraint and the tag_prefix, so editing the constraint invalidates the pin until ai-rulez lock. tag and tag_object are part of the tree digest; entries without a tag hash exactly as before. released and released_from are informational: they are not part of the digest and lock --check never compares them.

Minimum release age

A tag published a moment ago may be a compromised release. min_release_age makes lock and update take the newest tag the constraint allows that has existed for at least that long:

[lock]
min_release_age = "7d"             # default for every source with a version constraint
min_release_age_source = "auto"    # auto | forge | first-seen | commit

[[includes]]
name = "shared"
source = "https://github.com/example-org/ai-rules"
version = "^1.2"
min_release_age = "3d"             # per source; "0" turns the gate off for it

Ages are a whole number with h, d or w (12h, 7d, 2w), at most 3650 days. A tag the gate holds back is reported as AR733 (info) by lock, update and lock --outdated, and the next older tag that passes is used. The pinned tag is exempt: a gate never rolls a pin back, and lock keeps a pin that still satisfies its constraint without looking up any time. If every allowed tag is too young and the source is not pinned yet, there is nothing to pin: AR730, with the date the newest tag becomes eligible. Age is measured at the time of the run; lock --check does not re-evaluate it (the decision is in the lock, with released and released_from).

The release time comes from the first source that answers, in order of trust (min_release_age_source = "auto"):

  1. forge: the publish time of the GitHub release of the tag (Forge client). A committer cannot forge it, but a release keeps that time when its tag is force-pushed later, so the time counts only for the commit that was released. When the release records its commit (target_commitish is a commit id, as release tooling usually sets it), a tag that now points elsewhere is refused and the next source applies. When it names a branch, the forge cannot say which commit it released: the release time is then the later of the publish time and the first-seen time of the commit below, so a moved tag counts as new (with no first-seen record, forge fails closed). A tag without a release has none, so the next source applies. The token is read from GITHUB_TOKEN, GH_TOKEN or gh auth token and sent only to allowlisted hosts.
  2. first-seen: when this machine first saw the tag at that commit, kept in ~/.cache/ai-rulez/observed-tags.toml (local, uncommitted, bounded). A tag never seen before is "seen now", so it is held back, not waved through on a date anyone who can push could forge. lock --outdated on a daily CI job records every tag it sees, which builds that history. A tag that moves to another commit counts as new. The record is capped (5000 tags, 1000 per source, under 1 MiB), oldest dropped first. When it cannot be read or written, auto warns and holds the tag back.
  3. commit: the tagger date of an annotated tag or the committer date of the commit, read from a one-commit fetch. Anyone who can push can forge it, so auto never uses it; set min_release_age_source = "commit" to use it directly. It protects against accidental adoption only.

forge, first-seen and commit select one source and fail closed: a tag whose time cannot be found is held back. Consequence worth knowing: on a first run with a non-GitHub source and auto, every tag is "seen now", so a new project cannot lock a range until the age passes; use commit for such a source if that is acceptable, or pin a commit SHA.

lock --outdated

$ ai-rulez lock --outdated
SOURCE  KIND     CONSTRAINT  LOCKED  ALLOWED  LATEST  NOTE
shared  include  ^1.2        v1.2.4  v1.3.1   v2.0.0  update available; newer major available
deploy  skill    ~2.1.0      v2.1.3  v2.1.3   v2.2.0  up to date within the constraint
2 source(s): 1 updatable, 1 with a newer major, 0 moved tag(s), 0 error(s)

It lists tags only (one git ls-remote per repository, no content is fetched) and writes nothing. --format json follows schema/lock-outdated.schema.json. Each source has a status: up-to-date, updatable, not-locked, tag-moved (AR732), tag-missing (AR735), unsatisfiable (AR730), invalid (AR731), downgrade-only, or locked-non-version (the pinned tag is not a version, e.g. a constraint was added to a source pinned by an arbitrary ref; update moves it only with --allow-downgrade). Names and --kind include|skill|source limit the report; a name that matches no source with a version constraint is an error (exit 1), as with update. Tags that differ only in build metadata (v1.2.3+a, v1.2.3+b) have equal precedence: the first by name is used and a note says so. With a minimum release age the ALLOWED column is the newest tag that is old enough, the held tags are listed as AR733 in the note (held_back in JSON) and counted in the summary. [lint.severity] AR734 = "warning"|"error" reports every updatable source as an AR734 finding (code and severity in JSON); error also exits 2. Off by default. Exit codes: 0 (also when updates exist), 2 with --fail-on-outdated when any source has an allowed update, and always 2 for a moved or deleted tag (AR735) or an unsatisfiable constraint, 1 when it could not run. It needs the network: --offline (or --offline) refuses with a hint, and lock --check stays the offline verification. A scheduled CI job can run ai-rulez lock --outdated --format json --fail-on-outdated.

update

$ ai-rulez update --dry-run
include shared: v1.2.4 -> v1.3.1 (b21c0f3e1a9d)
  M  rules/security.md
  A  skills/deploy/scripts/run.sh
  tree sha256:... -> sha256:...
  run `ai-rulez generate`, then `ai-rulez lock` (it refreshes the output pins and the served-skill pins, which stay stale until then)
would update 1 source(s)

$ ai-rulez update shared          # rewrites the lock only
$ ai-rulez update --kind skill    # only installed skills

update [name...] [--kind include|skill|source] [--dry-run] [--allow-downgrade] [--accept-moved-tag] [--accept-findings] [--major [--write-config]] [--format json] moves the named range sources (all of them without names) to the newest allowed tag and records the tag, commit and tree digest. It edits config.toml only with --major --write-config. --dry-run fetches the new trees (into the cache) to digest and compare them, and writes no lock. --format json follows schema/update.schema.json. The file list compares the old cached tree with the new one; it is omitted when the old tree is not cached. Output pins and content pins of other items are not recomputed (as with lock <name>): run generate and lock after.

  • Moved tags (AR732). A tag that now points to another commit than the pinned one is refused with exit 2 and nothing is written; lock refuses it too. Review the new commit, then update --accept-moved-tag re-pins that tag at its new commit. A tag that was deleted (AR735) only warns in lock/update (the pinned commit is still used), but lock --outdated counts it as an error.
  • Downgrades. update never selects a tag with lower precedence than the pinned one (a truncated tag list, a mirror, an attacker can roll a pin back) unless --allow-downgrade. Withheld newer tags are undetectable; --outdated can only compare what the remote advertises.
  • Unsatisfiable (AR730). No tag satisfies the constraint, or the repository has no version tags: exit 2.
  • Held back (AR733). With a minimum release age the newest tag that is old enough is chosen; the held tags are listed per update (held_back in JSON) and the release time is recorded in the lock.
  • Scan before pinning. The new tree gets the security scan (AR001-AR009, the one approve and served skills use, no inline ignore comments) before the lock is written. Error findings refuse the pin (exit 2, nothing is written, scan.refused in JSON); review them, then --accept-findings. Warnings are listed. --dry-run runs the scan too and exits the same way. A tree over the file or size limits, or not in the cache, says so in scan.note. ai-rulez lock runs the same scan over every remote tree it pins to something new (a new entry, or a changed commit or digest; unchanged pins were scanned when pinned) and takes the same --accept-findings.

Taking a new major version

update --major looks at the sources that have a newer major version than their constraint allows, and prints the constraint that takes it (version = "^2.0" for v2.3.1):

$ ai-rulez update --major
include shared: newer major v2.0.0: version = "^2.0" (now "^1.2"); `update --major --write-config` applies it
$ ai-rulez update --major --write-config shared

Without --write-config nothing is written. With it, ai-rulez rewrites only the version = "..." value of that entry in config.toml (or its ref shorthand; comments, key order and layout stay, and the patched file is checked to differ from the original in that one value), then moves the pin to the newest tag the new constraint allows, with the same refusals as a plain update. If anything is refused (a moved tag, scan findings) or fails, config.toml is restored byte for byte. The source must be a [[includes]], [[installed_skills]] or [[skill_sources]] table of the project's own config.toml with a name key; any other form (an inline array, a duplicate name, a source that comes from an include or the local overlay) is refused with a hint to edit by hand. Other sources are untouched by --major; --dry-run never writes the config. --write-config without --major is an error.

Checking pinned tags online

lock --check and generate stay offline. When you want the remote's word that the pinned tags did not move, opt in: lock --check --verify-tags, generate --verify-tags, or [lock] verify_tags = true for both. One git ls-remote per repository (no content is fetched) compares each locked tag with the commit it pins: a moved tag is AR732 (error, exit 2, generate writes nothing), a deleted one AR735 (warning, the pinned commit is still used). An unreachable remote is exit 1. The key is skipped quietly under --offline, --frozen and --offline; the flag with them is an error. generate --recursive verifies the tags of every root it processes; a root with a moved tag fails (exit 2 when every failure is drift).

ai-rulez skill update is lock --kind skill: it re-resolves plain refs (a branch follows its tip) and keeps range pins as they are. Use update --kind skill to move range pins.

Design decisions

  • version is the constraint key; ref is accepted for it only with ^, ~, * or a space (the issue's proposal). The constraint grammar is hand-written in internal/semver rather than a library, so prerelease behavior is ours and tested. The lock ref holds the constraint text, so a binary from before this feature sees a pin it cannot match and asks for ai-rulez lock.
  • The moved-tag check needs the remote, so it runs in lock, update and lock --outdated; lock --check and generate stay offline by default and run it only with --verify-tags or [lock] verify_tags = true.
  • lock --outdated consults the forge only when a min_release_age is set (the question 4 of the design: lookups only when the answer is used). --major suggests ^MAJOR.0, the design's ^2.0, not the exact latest.
  • The first-seen source treats an unseen tag as released now (held back), and auto never falls back to the commit date: when the record cannot be kept the tag is held back with a warning, so a forgeable answer never loosens the gate.
  • Rule codes: AR730 unsatisfiable, AR731 invalid or both ref and version, AR732 tag moved, AR733 tag held back by min_release_age (info), AR734 source outdated (off; enable it in [lint.severity], see below), AR735 pinned tag missing.

Hashing scheme

All digests are SHA-256, written sha256:<64 hex digits>. The scheme is part of the lock format: any change to it bumps the lock version, and a lock of another version is refused. (The version 1 to 2 step changes only what a lock may hold, not the scheme, so hash_version below stays 1.)

Notation: lp(x) is the 8-byte big-endian length of x followed by x; u64(n) is n as 8 bytes big-endian.

File leaf (one file of an item):

leaf = SHA256( lp("ai-rulez/file/v1") || lp(path) || lp(mode) || lp(data) )
  • path is relative to the item, /-separated, with no ., .., empty segment or backslash.
  • mode is the string 100755 if the file is executable, else 100644. Nothing else about the file mode matters. On Linux and macOS the owner execute bit decides (0o100), because git records only that bit (100755 versus 100644); group and other execute bits are ignored. Windows filesystems have no execute bit, so there the mode is taken from the git index (git ls-files -s, the mode git checks out on Unix); if git is not available or the file is not tracked it is 100644. A checkout whose repository records the executable bit therefore pins the same digest on every operating system. A script that is executable on disk but not recorded as such in git will pin differently on Windows than elsewhere; commit the bit (git update-index --chmod=+x). Remote trees are digested the same way (the mode helper is shared).
  • data is the raw bytes on disk, never the frontmatter-stripped text the loader keeps in memory. For documents and data files (.md .markdown .mdc .mdx .txt .toml .yaml .yml .json .jsonc) CRLF is converted to LF first, so a Windows checkout with autocrlf pins the same digest. A lone CR is kept. Every other file (images, binaries, extensionless files) and every script (.sh .bash .zsh .py .js .mjs .cjs .ts) is hashed byte for byte: a CRLF in a shell script changes behaviour (#!/bin/sh\r fails with "bad interpreter"), so a script whose only change is its line endings pins a different digest. Keep scripts LF in git (.gitattributes: *.sh text eol=lf).

Item tree (domain-separated per kind: rule, context, skill, agent, command, check, hook, role, verifier, rubric, settings, output, include, okf-include, installed-skill, skill-source, served-skill):

digest = SHA256( lp("ai-rulez/<kind>/v1") || u64(n) || leaf_1 || … || leaf_n )

with the n leaves (32 bytes each) sorted by path, bytewise. Duplicate paths are an error. A single-file item is a tree of one leaf named after the file (SKILL.md, style.md); a declared item (a hook, a role, a settings source) is a leaf holding its compact JSON encoding (struct fields in declaration order, map keys sorted), plus one leaf per hook script.

Top-level tree:

tree = SHA256( lp("ai-rulez/tree/v1") || u64(n) || { lp(kind) || lp(key) || lp(digest) }… )

over every pin, sorted by (kind, key), where kind/key are item/<kind> with <domain> NUL <id>, output with the path (role pins: kind output-role, key the role name), include, installed-skill, skill-source and served-skill with the name and <commit> <digest>, and digest is the sha256:<hex> text of the pin.

Two items with the same kind, domain and id get a #2 suffix on the second (in path order), so every key is unique.

Test vectors

These were computed independently (Python's hashlib) and are checked by the test suite.

Input Digest
leaf SKILL.md, mode 100644, data # Title\nbody\n cbac10c14a090ab0301ad17b0013e8c960c5960d5574dcabf38867b98987c621 (hex, no prefix)
tree rule: style.md = # Style\n sha256:f9c0b1ef53a34e543828ff3459f4f117e08edc2976c22a3ee32d0a65ee212c9c
the same with # Style\r\n the same digest (CRLF normalized)
tree context: empty.md = (empty) sha256:6e09fc1d734e8a2db85a25265407b4e78e44e45e712d4f60d2682c121f95c40b
tree rule with no files sha256:c36b2dc178586a67dfcf7bc1e18ab82e0aa267eb5c5a7d7cba0a6ee7b062ec26
tree skill: SKILL.md = ---\nname: deploy\n---\nDeploy.\n (100644), scripts/run.sh = #!/bin/sh\necho hi\n (100755), assets/logo.bin = bytes 00 01 0d 0a 02 (100644) sha256:262d721306783b4c3b554a1345253c266f6f991733dad6a347e1b55d5e57ac05
the same skill with run.sh at 100644 sha256:aea011299a41f59be23cee1a602ab3c03fb3e60aa6ce4dc31f947255925d5c73
top digest of (item/skill, "\0deploy", <the skill digest above>) and (output, "CLAUDE.md", "sha256:" + "ab"×32) sha256:6cd1d810fce0e91263b3ebfa3610821a820a07b415a8f2a4b9415de191b6c24e
tree skill: run.sh = echo hi\n (100755) sha256:d7aec0e55512be14e8c20554ee6da9911881211fec26eec3927bb18b21212b54
the same with echo hi\r\n (scripts are not normalized) sha256:5b9fcff355356346b64a0f8146b7c65b765f377d1deddf29318ef9e686e7ee92
tree installed-skill: SKILL.md = # S\n sha256:018df4aac28d9eca573a05cb491c6704407d9b5de8ca10e0dfe20300928140db
tree skill-source: SKILL.md = # S\n sha256:cf90b42b8c9bbb2e9bc94075e4d8a183117f45b46d0b746b7bc617587f64bc7b
tree served-skill: SKILL.md = # x\n sha256:2225664563ac4bc0affa10d8ca1ea4dcd5ed2a61792b124824005493dffb458c
directory digest, kind include: rules/a.md = # A\n (100644), hooks/x.sh = #!/bin/sh\n (100755) sha256:94a2c6de5e10aa7eef64adb55330c4a84c02d49566d56adcedbd7f5db2ddf01c
the same directory, kind okf-include sha256:990b39514f6589c5e61d584b7f59173cb8b1a3bd260ee93af92ebedc819da5e7
top digest of no entries sha256:e8c93a22e1ed47e16dd881a55dc4fbc5ba685af20083b93469265da901784029

The remote-source digest of an include, OKF include, installed skill or skill source is the directory digest above: there is no second algorithm.

Signing the lock

The lock proves that bytes did not change; it does not say who produced them. To add that, sign the lock and verify the signature in CI. ai-rulez sign --lock and ai-rulez verify --attestation do both with a [signing] policy (see Signing). ai-rulez lock --subject prints what is signed, so you can also use cosign with no signing code in ai-rulez; the recipe below stays valid, and verify --attestation accepts the bundle it writes.

What is signed is the lock subject, not the bytes of ai-rulez.lock. The file is TOML that tools rewrite; what matters is its meaning, so the subject is a digest over the recomputed tree and the settings the pins were written with. Line endings and key order do not matter, and a pin edited by hand changes the subject.

lock_subject = SHA256( lp("ai-rulez/lock-subject/v1") || lp(tree) || lp(approvals_digest)
                       || u64(hash_version) || lp(scope) || u8(outputs_pinned) )
  • tree is recomputed from the lock entries, never read from the file. lock --subject fails (exit 2) when the stored tree disagrees, so a hand-edited lock is not signed.
  • approvals_digest is sha256( lp("ai-rulez/approvals/v1") || records ) over every [[approval]] and [[deny]] record in the order the lock writes them, each field length-prefixed (kind, domain, id, digest, reviewer, assurance, approved_at, expires, note, accepted findings, ref, attestation; then the deny digest and reason). It is the empty string while the lock holds neither, so a lock without approvals has the subject it always had. Approvals stay outside tree; inside the subject, adding, editing or removing one changes what a signature covers, so sign (and re-sign) after the last approve.
  • hash_version is 1, the lock version: the hashing scheme is part of the lock format.
  • scope is the recorded [lock] scope (all when the lock records none); outputs_pinned is 1 or 0.
  • lp(x) and u64(n) are defined in Hashing scheme; u8 is one byte.
$ ai-rulez lock --subject
sha256:60e6900f…  (lock-subject/v1; tree sha256:6cd1d810…, approvals none, hash_version 1, scope all, outputs_pinned true)

$ ai-rulez lock --subject --output lock-subject.json

--output writes the statement schema/lock-subject.schema.json describes. It is deterministic (fixed field order, trailing newline), so the file a verifier recomputes is byte-identical to the one that was signed:

{
  "schema_version": 1,
  "type": "ai-rulez/lock-subject/v1",
  "subject": "sha256:60e6900f7b4f0dfa733cc2ac84f7c8de74f7a2a5724cf0951ce4af4b24e39cb9",
  "tree": "sha256:6cd1d810fce0e91263b3ebfa3610821a820a07b415a8f2a4b9415de191b6c24e",
  "approvals_digest": "",
  "hash_version": 1,
  "scope": "all",
  "outputs_pinned": true
}

Sign in the release workflow

Keyless, from GitHub Actions (the signer is the workflow identity):

permissions: { id-token: write, contents: read }
steps:
  - run: ai-rulez lock --check
  - run: ai-rulez lock --subject --output lock-subject.json
  - run: cosign sign-blob --yes --bundle .ai-rulez/ai-rulez.lock.sigstore.json lock-subject.json

Commit .ai-rulez/ai-rulez.lock.sigstore.json next to the lock (or publish it with the release). With a key instead of an OIDC identity: cosign sign-blob --key cosign.key --bundle ... lock-subject.json. The default sidecar of ai-rulez sign --lock has the same name (a DSSE attestation instead of a blob signature); either kind verifies with ai-rulez verify --attestation. Do not mix the two in one release.

Verify

Recompute the statement from the committed lock, then verify the bundle against it. cosign must be told who may sign; "any valid signature" is not a check:

ai-rulez lock --check                         # the lock still matches the sources
ai-rulez lock --subject --output lock-subject.json
cosign verify-blob \
  --bundle .ai-rulez/ai-rulez.lock.sigstore.json \
  --certificate-identity "https://github.com/example-org/ai-config/.github/workflows/release.yml@refs/heads/main" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  lock-subject.json
# with a key: cosign verify-blob --key cosign.pub --bundle ... lock-subject.json

Because the statement is recomputed from the lock in the checkout, verification fails when the lock changed after signing, when a pin was edited, or when the bundle belongs to another repository's lock. lock --check is still needed: it compares the lock with the sources; the signature only says who vouched for the lock.

Limits, stated plainly: a valid signature means "produced by that identity", not "safe". The cosign recipe has no freshness or rollback check, so pin the signer and review lock changes. ai-rulez verify --attestation adds an identity policy, max_age and a per-machine rollback check on top of the same bundle (see Signing). Signing plugin bundles and served skills is not implemented.

Commands

ai-rulez lock                 # pin remotes (network) and content; writes ai-rulez.lock
ai-rulez lock --content-only  # re-pin authored content and outputs only; offline, remote pins kept
ai-rulez lock shared          # re-pin one include or skill; content pins are kept as they are
ai-rulez lock --check         # verify everything, offline; exit 2 on any difference
ai-rulez lock --outdated      # sources whose version constraint allows a newer tag (network)
ai-rulez update --dry-run     # what moving the range pins would change
ai-rulez lock --diff          # show what `ai-rulez lock` would change; exit 0
ai-rulez lock --diff --format json
ai-rulez lock --check --format json   # the --diff document on stdout; exit 2 on any difference
ai-rulez lock --subject               # the digest a signature over the lock commits to; offline, read-only
ai-rulez generate --locked    # also fail when an authored source differs from the lock
ai-rulez generate --frozen    # --locked without the network

lock writes deterministically: running it twice produces the same bytes. Its output profile is --profile, else the profile recorded in the lock, else the configured default.

generate never writes the lock. Accepting a change is always an explicit ai-rulez lock.

lock --check

Offline. It compares the remote pins with the local cache (as before), every authored item with its pin, and the rendered outputs with the output pins, and exits 2 after naming each difference:

ai-rulez.lock does not match /work/app/.ai-rulez:
  source added    rule security (rules/security.md)
  source changed  skill backend/deploy (domains/backend/skills/deploy)
  source removed  hook PreToolUse:Bash:0
  output changed  output .claude/skills/deploy/SKILL.md
run `ai-rulez lock` to refresh it (after reviewing the change with `ai-rulez lock --diff`)

source lines say an authored item changed; output lines say what agents see changed. A source change normally brings output changes with it, but an output can change alone (a new ai-rulez release, a different include revision), and that is worth a look too. Exit codes: 0 in sync, 1 the command could not run, 2 differences, and for lock itself 3 when the lock was written but served skills were left unpinned because the security scan refuses them (--strict writes nothing and exits 2 instead). Over several roots (--recursive) the most severe code wins: 1, then 2, then 3.

With no ai-rulez.lock at all, --check exits 1 ("no ai-rulez.lock": there is nothing to verify, which is not the same as in sync), and under [lock] enforce = true it exits 2 as a drift. Run ai-rulez lock first.

A change of the ai-rulez version is a note, not a failure: output digests can differ between releases, and the output lines then tell you which.

A lock without content pins (one written by lock <name> before any content was pinned) has none to compare, and a lock whose pins were stripped looks the same. --check therefore fails on it (exit 2) and asks for ai-rulez lock, whatever enforce says: a check that passes on such a lock would let a downgrade switch the content checks off. generate keeps using the include and skill pins of such a lock. generate --locked on it warns, and fails under enforce. A lock with content pins must also carry a tree digest. A hook script (or a frontmatter hook script) outside the project cannot be pinned; it is reported as a lock change (not an abort) until it moves inside the project.

If remote includes are configured but not in the local cache, outputs cannot be rendered as generate would; the check then compares sources only and says so in a note, or fails under enforce = true. Only a cache miss falls back this way: a cached include that violates its pin, or any other load error, is reported as the error it is.

lock --diff

The same comparison, printed for a pull request description or a bot, and always exit code 0. --format json prints the document described by schema/lock-diff.schema.json:

{
  "schema_version": 1,
  "in_sync": false,
  "lock_version": 1,
  "changes": [
    { "scope": "source", "change": "changed", "kind": "skill", "id": "deploy", "domain": "backend",
      "path": "domains/backend/skills/deploy", "old": "sha256:…", "new": "sha256:…", "detail": "version \"1.0.0\" -> \"1.1.0\"" },
    { "scope": "output", "change": "changed", "path": ".claude/skills/deploy/SKILL.md", "old": "sha256:…", "new": "sha256:…" }
  ]
}

scope is source, output, remote (a remote pin no longer matches) or lock (the lock itself: hand-edited pins, or written with different [lock] settings).

Configuration

[lock]
enforce = true           # default whenever ai-rulez.lock exists; false opts out. Strict validation reports drift (and an unreadable lock), AR010 is an error, generate refuses an unlocked remote source, generate --locked requires content pins
include_outputs = true   # false: pin sources only
scope = "all"            # "skills": pin only skills, remote includes and installed skills
min_release_age = "7d"   # default minimum age of a tag for sources with a version constraint
min_release_age_source = "auto"  # auto | forge | first-seen | commit
verify_tags = false      # true: generate and lock --check ask the remotes whether a pinned tag moved

scope = "skills" is for projects that only care about the skill supply chain; it implies no output pins. The lock records the settings it was written with, and --check reports a mismatch so a changed [lock] table cannot silently weaken a check.

With enforce = true, and only when a lock exists, validate adds:

Code Meaning
AR981 lock-source-drift An authored item was added, removed or changed since the lock was written, the lock has no content pins, or the lock cannot be read or compared (corrupt, another lock version, an unpinnable source). Enforcement never skips a check it cannot run.
AR982 lock-output-drift A generated output differs from its pinned digest.

Both default to error; they can be tuned with [lint.severity] like any other code.

CI usage

Run the lock check next to the drift check:

- run: ai-rulez lock --check          # pins match: sources, outputs, remote cache
- run: ai-rulez generate --check      # the committed output matches the sources
- run: ai-rulez generate --locked     # fail if a source changed without a lock update

They answer different questions. generate --check asks "was the output regenerated after the sources changed?". lock --check asks "was the change approved?": a source edit, a new skill or a moved include has to come with a lock update, which is a separate, small, reviewable diff. On a pull request, ai-rulez lock --diff gives a summary to paste into the description.

Scheduled outdated report

A scheduled job tells you when pinned sources fall behind, and gives the first-seen record of a minimum release age its history. It reads tags only and writes nothing:

name: ai-rulez outdated
on:
  schedule:
    - cron: "17 6 * * *"          # daily
  workflow_dispatch:
permissions:
  contents: read
jobs:
  outdated:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g ai-rulez            # or: uvx ai-rulez
      - name: Report outdated sources
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}   # release dates; sent only to github.com
        run: |
          ai-rulez lock --outdated --format json --fail-on-outdated | tee outdated.json
      - name: Verify pinned tags did not move
        run: ai-rulez lock --check --verify-tags
      - if: always()
        uses: actions/upload-artifact@v4
        with:
          name: outdated
          path: outdated.json

--fail-on-outdated exits 2 when any source has an allowed update, so the job turns red until someone runs ai-rulez update and merges the lock; a moved or deleted tag and an unsatisfiable constraint fail it too. To report without failing, drop the flag and read summary.updatable and summary.held_back from the JSON. To make "outdated" a finding instead, turn on AR734 and let the exit code gate:

[lint.severity]
AR734 = "warning"     # report updatable sources as findings; "error" also fails lock --outdated

A job that updates for you is a separate, reviewed pull request: run ai-rulez update, then ai-rulez generate and ai-rulez lock, and open the diff. update never runs unattended, and each pin moves only after the scan of the new tree.

Reviewing lock diffs

The lock holds one small block per item, sorted by kind, domain and id, so git diff ai-rulez.lock reads like a change list:

  • a new [[item]] block is a new rule, skill, hook or role: open the file at path;
  • a changed digest with an unchanged version on a skill with a version is a review smell: the content moved but nobody bumped the version;
  • a changed owner line is a change of responsibility;
  • a new [[item]] of kind = "hook" or a changed hook is code that will run on developers' machines: read the script;
  • an executable skill script that appears or changes is reviewed like any script;
  • [[output]] churn with no [[item]] change means the rendering changed (a release, an include): check why.

The item digest does not tell you what changed; it tells you that it did. Pair it with the normal source diff. A reviewer who sees a lock change with no matching source change should ask why.

Composing with roles

Roles are pinned as items (kind = "role"), so a new role or a changed include / exclude list is a lock change. The lock pins the default rendering (one profile) as one [[output]] per file. A role renders a different tree, so a role's outputs are pinned only on request, as one aggregate digest per role:

[[output]]
role = "backend"
digest = "sha256:…"

A role is pinned when it declares pin = true; ai-rulez lock --roles pins every role, and lock --role <name> pins that role as well. The digest covers the files generate --role <name> would write (the same rendering the default pins use, plus the ai-rulez-rendered part of .claude/settings.json, so skillOverrides from skill_mode count). The role is rendered in memory; nothing is written. A plain lock re-pins the roles that declare pin = true and every role pinned with --roles or --role (the pin carries requested = true), which stay pinned. Removing pin = true (or setting it to false) unpins a role that only its own key pinned: lock --check reports the pin as removed and lock drops it. A pin whose role was removed from the configuration is dropped.

  • lock --check compares every pinned role and reports output changed outputs of role backend (or added for a role with pin = true that the lock does not pin yet, removed for a pin whose role is gone or no longer pinned). It also catches a change that leaves the sources alone, such as a new generator release or a delivery entry. --role <name> limits the role comparison to one role; naming a role that is not pinned is an error.
  • lock --diff adds, for a changed role, the per-file digests of its current rendering (files in the JSON), so a reviewer can see which files make up the new digest. The lock itself stores only the aggregate.
  • generate --locked --role <name> (and generate --check --locked --role <name>) verify every source and, when the role is pinned, its rendered outputs. A role without a pin behaves as before.
  • Roles without pin = true add nothing to the lock; its format is unchanged for a project that pins none. The aggregate is lp("ai-rulez/role-output/v1") over the role's files, and the top-level tree covers it under the kind output-role. An ai-rulez release that predates role pins reads such an entry as an output without a path.

generate --locked --role <name> therefore ties a person's role output to reviewed sources and, for a pinned role, to a reviewed rendering. The catalog command reports, per item, its digest and whether the sources are in sync with the lock.