Skip to content

Harness locks

A harness is a git directory of the harness's own native files plus a small harness.lock that pins them. hangar writes one from a turn that went well (hangar harness freeze), and CI in the team's harness repo checks it (hangar harness check). There is no harness spec language and no compiler: the files are what the CLI reads, and the lock is a frozen observation of a turn's manifest.

Format hangar.harness-lock/v1, YAML on disk; schema docs/api/harness-lock.schema.json
Identity harness_digest: equal to the harness_digest of the turn it was frozen from
Write one hangar harness freeze <session or job> [--turn N] --out DIR [--name NAME]
Check one hangar harness check DIR (or hangar harness freeze --check DIR); exit 1 on a mismatch
REST GET /api/sessions/{name}/harness?turn=N&name=NAME, GET /api/jobs/{id}/harness?turn=N&name=NAME
Run from one hangar harness thaw NAME@rN --env ENV [--session NAME]; harness_lock on a job or session (thaw and drift)
Upstream drift hangar harness outdated NAME (report only)

A harness directory

team-default/
  harness.lock
  CLAUDE.md                        # the harness's global instructions file (AGENTS.md for Codex)
  skills/review/SKILL.md           # one directory per skill
  plugins/team/                    # a Claude Code directory marketplace (#221)
    .claude-plugin/marketplace.json  # lists exactly the lock's plugins of this marketplace
    workflow/...                   # one directory per plugin, at its marketplace entry's source
  native/claude-code/settings.json # config fragments under the harness's own directory (~/.claude, ~/.codex)
schema: hangar.harness-lock/v1
name: team-default
revision: 14                    # monotonic; freeze bumps it when anything but the name changes
runtime: {harness: claude-code, version: 2.1.296, pack: "reg/hangar-pack-claude-code:..."}  # harnesses.lock.yaml
model: {id: claude-opus-5-5, policy: balanced}   # only the concrete id is identity
components:
  - {id: "guide:CLAUDE.md", digest: "sha256:...", path: CLAUDE.md}
  - {id: "skill:review", digest: "sha256:...", tree: "sha256:...", path: skills/review, source: "path:skills/review"}
  - {id: "skill:verify-ui", digest: null, source: "hangar:computer"}          # a reference: no files
  - {id: "plugin:workflow@team", digest: "sha256:...", tree: "sha256:...", path: plugins/team/workflow, enabled: true}
  - {id: "native:claude-code/settings.json", digest: "sha256:...", path: native/claude-code/settings.json}
binding:                        # rendered at claim; names, never values
  mcp: [{name: github, transport: http, endpoint: "https://api.githubcopilot.com/mcp/", header_names: [Authorization]}]
  hooks: [{event: Stop, source: "hangar:verify_gate"}]
  permissions: {approvals: deny-risky}
plugins: {autoUpdate: false}    # always false
remote: []                      # vendor-loop agents: [{id, version}]
  • Components are listed in order: the guide, the skills in the order the turn declared them, the plugins in the order the environment declared them, then native fragments. A file's digest is the SHA-256 of its bytes; a directory's is the manifest's declared skill digest (for each file in path order: its relative path, a NUL, the raw SHA-256 of its content). A skill also carries tree, the digest the sandbox probe reports for the same files (the manifest's skills.effective), so a turn's effective skills can be compared with the lock without the source. A component with digest: null is a reference (a pinned git skill, hangar's verify-ui): it has a source and no files.
  • Plugins (#221) are Claude Code seed plugins (harnesses.md): plugin:<name>@<marketplace>, the plugin's directory at plugins/<marketplace>/<its entry's source>, and its marketplace entry in plugins/<marketplace>/.claude-plugin/marketplace.json, so plugins/<marketplace> is a directory marketplace Claude Code installs from. digest is the manifest's (the entry as canonical JSON, a newline, the directory's digest), tree what the probe reports for the directory, enabled whether hangar's settings enable it (an installed, disabled plugin is pinned too). Only a plugin with a relative (./) source can be frozen: one its marketplace fetches from elsewhere at build has no files to pin.
  • The binding layer holds names only, as the manifest does: MCP endpoints without query or user info, header and env variable names, hook sources, the approval mode. Credentials stay in the environment.
  • Not in a lock: the task repo's own AGENTS.md / CLAUDE.md (task context; the manifest keeps it under instructions.repo), knowledge snapshot ids (manifest and pin set), egress, credentials and approvals policy (the environment and governance).

Identity

harness_digest is the lock projected onto the manifest's harness sections and digested with spec 0014 ยง2's canonical form (harness_lock.identity, manifest.section_digest):

Manifest section From the lock
harness.name, harness.locked runtime.harness, runtime.version
model.id model.id
instructions.global.declared.digest the guide: component's digest (null without one)
skills.declared[*].{name,digest} the skill: components, in order
plugins[*].{name,marketplace,digest,enabled} the plugin: components, in order (null without any)
mcp_servers[*].{name,transport,url,headers,env,command,args} binding.mcp (endpoint, header_names, env_names)
hooks[*].{event,source,digest} binding.hooks
permissions.approvals binding.permissions.approvals

So a lock frozen from a turn has that turn's harness_digest, and a turn that runs from the lock reports it again. The name, revision, paths, provenance (source, pack), the model policy and native fragments that carry no hooks are not identity: renaming a harness or moving a file doesn't change it. hangar harness check prints it.

Freezing a turn

uv run hangar harness freeze job-1a2b3c4d --turn 1 --out ../team-harnesses/team-default --name team-default
uv run hangar harness check ../team-harnesses/team-default

Freeze reads the turn's stored manifest and recovers each piece from where the manifest says it came from, then checks it hashes to what the turn declared:

  • the instructions file from the manifest's declared text, or, when that was truncated (over 16,000 characters) or redacted, from the experiment overlay or the environment, if either still has the declared digest;
  • each skill from an experiment trial's stored files (the experiment read them when it started), else the environment's skill path;
  • each plugin from the environment's marketplace directory (its entry and its directory must still have the turn's digest); a seed whose copy differed at turn start (an image built before the plugin changed) is a warning: the lock pins the declared files;
  • native fragments from the environment's config files under the harness's own directory (agent_config.files and files); a settings file's hooks digest must still match the turn's. Claude Code and Codex are supported; pi and Antigravity are frozen best effort, with a warning.

It fails, naming each gap, when content can't be recovered:

  • a skill whose copy in the sandbox at turn start differed from its declared source (changed in the sandbox, for example by the agent in an earlier turn): freeze a turn from before the change;
  • instructions truncated or redacted in the manifest with no source left that has the declared digest;
  • a source edited or removed since the turn, an unpinned git skill, a plugin its marketplace fetches from elsewhere;
  • any file the broker's redaction would change: it holds a stored secret, and a secret never goes into a harness repo.

Skills in the sandbox that the turn didn't declare (hangar's own hangar-environment, or one added there) are not frozen, with a warning. Freezing into a directory that already has a lock keeps its name (unless --name is given), replaces only the paths the old and new lock manage, and keeps the revision when nothing changed or bumps it by one. Before it writes, it checks that directory against its own lock (as hangar harness check does): a file edited by hand, a missing component or a skill directory the lock doesn't pin would be lost, so freeze refuses, listing them, until you commit or revert them; --force overwrites anyway, with a warning per change (#251). Freeze writes files only: it never commits; open the PR to the harness repo yourself.

A member can freeze only their own sessions and jobs; an admin, anyone's; a jobs token, the jobs it submitted. A turn they can't see answers as missing.

Checking a directory

hangar harness check DIR validates the lock and recomputes every component's digest (and each skill's and plugin's tree). It also fails on what the lock doesn't pin, so nothing ships unpinned: a directory under skills/, a file under native/, a marketplace under plugins/ none of the lock's plugins comes from, a directory in one that isn't a locked plugin's, or a marketplace entry for a plugin the lock doesn't have. Other files in the directory (a README, CI config) are ignored. Use it as the harness repo's CI check (#138).

The team harness repo

Locks live in a git repo of their own, not in hangar (#138): a harness change is a content change the team reviews there, on its own cadence and CODEOWNERS, without a hangar release. hangar and flightdeck read it; neither owns it. Where it lives is the owner's choice (a team's real harness may be internal to its employer); a public example is built separately in agent-factory (agent-factory#4).

team-harnesses/
  harnesses/team-default/{harness.lock, CLAUDE.md, skills/*/SKILL.md, plugins/<marketplace>/..., native/...}
  channels.yaml                 # {team-default: {stable: sha256:..., canary: sha256:...}}; moved only by promotion PRs
  .github/workflows/check.yml   # `hangar harness check` on every harness directory
  CODEOWNERS                    # channels.yaml: two reviewers (approver != author, with branch protection)

Pointing hangar at it. HANGAR_HARNESS_REPO is either a local directory (a checkout you keep current yourself) or a git URL with an optional #ref (a branch, tag or commit; default: the repo's default branch), for example https://github.com/acme/team-harnesses#main. For a URL, hangar keeps a full checkout of its own under <data>/harness-repo/ on the control plane (full history, so name@rN and older digests resolve) and fetches it at startup, every HANGAR_HARNESS_REPO_REFRESH_SECONDS (10 minutes) and at once when GitHub sends a push webhook for that repo (add the push event to the App's or repo's webhook; otherwise the 10-minute refresh picks it up). A private repo is read with the control plane's git credential (HANGAR_GATEWAY_CREDENTIAL, else the GitHub App with contents: read), never a sandbox token; the token reaches git through its environment, not its command line or the checkout's config. A failed fetch keeps the last good checkout. The control plane stays up when the sandbox pools are powered down, so refreshes go on overnight. POST /api/harness-locks/refresh (admin) fetches it now.

Listing. GET /api/harness-locks lists each harness with its current revision and digest, and each channel's digest and the revision it resolves to, plus the registry's state (source, commit, last refresh, error).

hangar doctor reports the repo: reachable (with the commit and the last successful refresh, or the error and the fix), and whether every channel in channels.yaml resolves to a lock. It fails when HANGAR_DEFAULT_HARNESS is set without a repo, or names a harness the repo doesn't have.

The default for intake jobs. HANGAR_DEFAULT_HARNESS=team-default@stable (off by default) makes intake jobs (from GitHub, Jira, Slack and inbound webhooks) run from that lock when they name no harness, model or lock of their own, with an event saying which revision. A default that can't be claimed (the repo unreachable, the environment unable to run the lock) never blocks intake: the job runs as before, with a warning event. Jobs from the API, MCP and CLI are unchanged; they pass harness_lock themselves.

Bootstrapping team-default. Pick a job that went well (green verify, merged PR), then hangar harness freeze <job> --out harnesses/team-default --name team-default, set stable in channels.yaml to the digest hangar harness check prints, and open the PR to the harness repo. Thaw it once on a real ticket (harness_lock: team-default@stable) and record that turn 1 had no drift.

Not yet: warm pools per prefix-layer digest (a thawed session takes a sandbox like any other and mounts the lock into it), mounting native fragments from the lock (they still come from the environment's agent_config.files; their hooks are compared through harness_digest).

Moved from this page

Section Now on
Thaw and drift Thaw and drift
Codex plugins Thaw and drift