Lock file and workflow drift
KiCI uses a two-artifact model: TypeScript workflows are the source of truth; the lock file (kici.lock.json) is the execution contract. The orchestrator reads only the lock file to match triggers and decide cache vs build. Keeping these in sync is important.
Why the lock file matters
Section titled “Why the lock file matters”- Orchestrator fetches the lock file at the commit SHA and uses it to evaluate triggers and to look up the cached
.kici/source tarball +node_modulestarball. It never runs your TypeScript. - Agents download the cached source tarball (or, on cold cache, the build agent clones + packs it), register the shared TypeScript loader hook, and dynamic-
import()the workflow.tsdirectly. The lock file’s per-workflowcontentHashidentifies the expected contents of the whole.kici/directory and is verified against the extracted source before any step runs. The tarball’s own bytes are verified against the digest the orchestrator dispatched, and the restored tree replaces.kici/rather than being unpacked over it, so a file you deleted does not survive a cache hit.
If you change a workflow file (.ts) but do not regenerate and commit the lock file, the repo at that commit has drift: the lock file no longer matches the source. Triggers and cache keys can be wrong, and runs can fail with a clear “stale lock file” error once the agent verifies the hash.
Lock file structure
Section titled “Lock file structure”The lock file (kici.lock.json) is a JSON file with the following top-level fields:
| Field | Description |
|---|---|
schemaVersion | Lock file schema version, stamped by the compiler that produced the lock. Incremented on every format change. The orchestrator accepts a range of versions — see schema compatibility window — rather than requiring an exact match. |
minReaderVersion | The oldest orchestrator schema version that can read this lock: the newest breaking version at compile time. An orchestrator whose own schema is below this rejects the lock and asks you to upgrade it. Required: the orchestrator rejects a lock without it. See schema compatibility window. |
source | Reference to the source file and export (e.g., { file: '.kici/workflows/ci.ts', export: '#default' }). |
contentHash | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. |
lockfileHash | SHA-256 of the detected package manager’s lockfile, used as the dependency cache key. The lockfile is .kici/package-lock.json for npm (the repo-root package-lock.json when .kici is an npm workspace member), or the repo-root pnpm-lock.yaml / yarn.lock for a pnpm/yarn workspace; the hash input is prefixed with the manager name so a manager change is a guaranteed cache miss. Omitted when no lockfile exists. |
siblingsDigest | SHA-256 over the git-tracked source of every in-repo sibling package .kici depends on, transitively: a workspace: / file: / link: / portal: specifier, or a version range that npm or yarn links to a workspace package of the same name. Part of the dependency cache key alongside lockfileHash, because editing a sibling’s source moves no package manager lockfile. Omitted when .kici depends on no in-repo package, which is the common case. |
workflows | Array of workflow entries, each with its own contentHash, compileSchemaVersion, triggers, and jobs. |
Each workflow entry includes:
| Field | Description |
|---|---|
name | Workflow name. |
source | Per-workflow source file and export reference. |
contentHash | SHA-256 of a digest over the whole .kici/ directory mixed with compileSchemaVersion (and an assetDigest of declared hashFiles when present): SHA-256(compileSchemaVersion + ":" + treeDigest [+ "\0" + assetDigest]). The tree digest covers every file under .kici/ except the paths declared in .kici/.kiciignore — see files the content hash skips. Paths are sorted and line endings normalized. The orchestrator uses this as the source-tarball cache key and the agent re-computes it against the extracted tree to detect drift. |
compileSchemaVersion | Compiler schema version used when computing contentHash (currently 7). The hash input is line-ending-normalized (CRLF → LF) so a lock file produced on Linux matches the agent’s hash on Windows where Git’s core.autocrlf=true rewrites checked-out text to CRLF. Bumping the schema version invalidates every existing source cache entry even if source is unchanged, which is the correct behavior when the compile-time or runtime contract changes. An agent rejects a lock whose compileSchemaVersion differs from its own; see compile schema mismatch. |
triggers | Trigger definitions extracted from the workflow (used by the orchestrator for event matching). |
jobs | Job definitions with scheduling metadata (runsOn, needs, matrix, contexts, concurrency, container, checkout, gracePeriod, label routing, dynamic fields, etc.). |
rules | Workflow-level conditional rules (optional). Stored as dynamic references since rule functions cannot be serialized. |
description | Optional workflow description. |
hashFiles | Declared glob patterns for extra files included in the content hash (optional). See extra files in the content hash. |
resolvedHashFiles | Resolved file paths from hashFiles at compile time (optional). Recorded so the agent can verify without re-discovering. |
contexts | Context names bound by every job of the workflow (optional). Each job binds them before its own contexts, so a job-level context wins a key collision. The orchestrator checks each one against the context protection rules for every job, the same as a job-level context. |
registries | Private npm registry declarations the agent authenticates against before install (optional): url, scope, tokenSecret reference, alwaysAuth. Resolved token bytes never appear in the lock file. See private registries. |
installEnv | Extra qualified secret refs (<context>:<secret-name>) projected as env vars on the install subprocess for use with a committed .kici/.npmrc (optional). See private registries. |
concurrency | Workflow-level concurrency config: hasGroup, cancelInProgress, max (optional). See concurrency groups. |
timeout | Whole-run wall-clock timeout in milliseconds (optional). The orchestrator reads this at run creation to set the run deadline. |
approval | Normalized approval gate (optional): clauses, reason, timeoutSeconds, when. When present the whole run is held before any job is dispatched. Job and step entries carry the same normalized block for job- and step-level gates. See approval gates. |
hasFilter | true when the workflow declares a workflow-level filter predicate (optional; omitted rather than false). The predicate itself is never serialized — the flag tells the orchestrator an agent must evaluate the workflow before any of its jobs is dispatched. See global workflows. |
| Hook flags | Boolean flags (hasOnCancel, hasCleanup, hasOnSuccess, hasOnFailure) indicating which lifecycle hooks are defined. Job entries additionally have hasBeforeStep and hasAfterStep. |
Step entries carry their own capability flags, so the orchestrator can reason about a step without loading your TypeScript:
| Flag | Meaning |
|---|---|
hasOutputs | The step declares an output schema, so the run has typed outputs to record for it. Present on every step entry. |
hasCheck | The step declares an idempotent check facet, so a run can be dispatched in check mode. See idempotent steps and check mode. |
hasWhenInSync | The step declares a whenInSync facet that produces its outputs when check reports no drift. |
hasRules | The step has conditional rules (evaluated agent-side). |
hasOnCancel | The step has an onCancel hook. |
hasCleanup | The step has a cleanup hook. |
The check, apply, and whenInSync closures themselves are never serialized — only the flags are. The agent re-evaluates the real workflow TypeScript.
Schema compatibility window
Section titled “Schema compatibility window”The orchestrator does not require the lock’s schemaVersion to exactly match its
own. Instead it accepts a compatibility window, so an additive SDK update no
longer forces every orchestrator sharing a fleet to upgrade in lockstep.
A lock is accepted when both hold:
- Its
schemaVersionis at or above the orchestrator’s oldest supported version, which is schema v42. Most schema bumps are additive — they add fields that older readers ignore — so a lock compiled by a newer SDK still loads on an older orchestrator. - The orchestrator’s own schema version is at or above the lock’s
minReaderVersion. This guards the case a version floor alone cannot detect: a lock that relies on a breaking change the orchestrator predates. For example, orchestrators before schema v42 run an organization-wide workflow without holding it for approval, so every lock requires schema v42 or newer.
Two out-of-window cases are rejected with an actionable error (recorded as a
lockfile_corrupt delivery, never a silent mis-route):
- Lock too old — compiled by an SDK predating a breaking schema change the
orchestrator relies on. Fix: recompile with a current SDK (
kici compile) and push the refreshed lock. - Lock too new (breaking) — requires an orchestrator newer than the one reading it. Fix: upgrade the orchestrator to the version the error names.
A lock can require the compiler’s own schema version. This happens right after a breaking
schema change, while the compiler’s version is also the oldest supported version. For such a
lock, kici compile prints a one-line notice that orchestrators older than that version
cannot read it. It is informational only — the orchestrator is the authoritative check.
Compile schema mismatch
Section titled “Compile schema mismatch”Each lock entry records the compile schema of the compiler that wrote it
(compileSchemaVersion). The schema is part of every content hash, so an agent
can only run a lock compiled for the schema it implements. Recompiling with the
same compiler gives the same schema, so it does not help.
A run of such a lock fails with an error that starts like this:
The workflow lock was compiled for a different compile schema: .kici/kici.lock.json in acme/ci-workflows declares compile schema 5, and the agents that run it implement schema 7.The error names the repository that holds the lock. For an organization-wide workflow, that is the workflow repository, not the repository whose event started the run.
The remedy depends on the direction:
- The lock is older than the agents (the usual case after an upgrade).
Install the
@kici-dev/compilerrelease of your agents, runnpx kici compile, and commit.kici/kici.lock.jsonin the repository the error names. - The lock is newer than the agents. Upgrade the agents to the KiCI release whose compiler wrote the lock, or recompile with the compiler release of your agents.
Where KiCI flags a stale lock before it runs:
- The workflows page of the dashboard shows a Recompile needed badge.
kici-admin workflow listmarks the lock as<schema> (stale)in itsschemacolumn and prints a warning line.kici-admin diagnosereports a Workflow lock compile schema row with statuswarn.- The orchestrator log warns when it registers the lock and when it starts.
kici-admin orchestrator upgradelists each stale lock once the upgraded service runs.
These checks compare each lock with the compile schema of the orchestrator’s own release. Run the orchestrator and its agents on the same release.
What happens to a run of a stale lock. On a single orchestrator, KiCI knows the schema of each agent that could run the job. When every such agent rejects the lock, the run fails before any agent starts, with the failure category “Lock-file / dependency resolution failed”. When an agent’s schema is unknown, or the orchestrator runs in a cluster, the run starts and the agent rejects the lock with the same error.
A re-run keeps the recorded lock. A re-run uses the lock that the original run recorded, so it fails again after you recompile. Only a new event runs the recompiled lock. The error of a re-run says so.
Rule: commit both together
Section titled “Rule: commit both together”Always commit .kici/kici.lock.json in the same commit as the workflow source files it was generated from.
- After editing
.kici/workflows/*.ts, run:Terminal window npx kici compile - Stage both the workflow file(s) and
.kici/kici.lock.json. - Commit them together.
That way the lock file at every commit SHA matches the workflow source at that SHA.
Catch drift early: pre-commit and CI
Section titled “Catch drift early: pre-commit and CI”Use automation so drift is caught before it reaches the repo.
Pre-commit hook
Section titled “Pre-commit hook”Install a hook that compiles and stages the lock file before each commit:
npx kici hook installBefore each commit, the hook runs kici compile and then git add .kici/kici.lock.json. It runs the kici version that installed the hook. If compilation fails, the commit is blocked. If it succeeds, the updated lock file is staged. See CLI Reference — kici hook for options (husky, lefthook, pre-commit, prek, raw git).
CI check
Section titled “CI check”In your CI pipeline, verify that the workflow source compiles without errors:
kici compile --checkThis validates all workflows and generates the lock file in memory without writing it. It also type-checks the workflow sources with tsc --noEmit. The type-check is skipped when .kici/ has no tsconfig.json. If any workflow has syntax errors, type errors, or invalid configuration, the command exits non-zero. Pair this with the agent-side hash verification (below) for full drift detection — --check catches broken source, while the agent catches source-lock-file mismatches at run time.
Files the content hash skips (.kici/.kiciignore)
Section titled “Files the content hash skips (.kici/.kiciignore)”The per-workflow content hash covers everything under .kici/ except the paths declared in .kici/.kiciignore. kici init writes that file for you with this default set:
node_modules/types/.npmrcpackage-lock.jsonpnpm-lock.yamlkici.lock.jsonEvery entry except kici.lock.json names something KiCI itself regenerates. The agent installs your workflow’s dependencies before it re-checks the hash, and that install rewrites package-lock.json, pnpm-lock.yaml, .npmrc and node_modules/; kici compile refreshes types/ after it has already hashed the tree. Hashing any of them would make the hash change on every run, and the drift gate would reject work that never changed.
kici.lock.json is different: the hash is written into that file, so hashing it would make it an input to itself. It stays excluded whatever your .kiciignore says.
Patterns are gitignore-style and are matched relative to .kici/. A trailing / matches a directory and everything beneath it, a bare name matches at any depth, and a pattern containing a slash is anchored at .kici/.
One rule differs from git: a symlink to a directory counts as a directory. So node_modules/ covers a .kici/node_modules that is a symlink into a shared dependency tree, where git would treat that link as a file. The exclusion means “skip the dependency tree, whatever shape it takes on disk”. Hashing the link instead produced a hash your build agent could not reproduce, because its own dependency install always writes a real directory there.
A symlink the exclusions do not cover is still hashed — as its link target, not as the bytes behind it. The source tarball has to carry that link unchanged for the agent to agree. So kici compile warns about a link it cannot carry: one whose target is absolute (extraction strips the leading /), or whose target points outside .kici/’s parent (extraction drops the link). Point the link inside .kici/, replace it with the files it names, or list it in .kiciignore.
.kiciignore is itself covered by the hash. Which files define a workflow’s identity is part of that identity, so editing the file forces a recompile — and nobody can change what a lock file attests to without changing the lock file.
Not the repo-root
.kiciignore. A.kiciignoreat the root of your repository is a separate, unrelated file: it selects which working-tree fileskici run remoteuploads. Only the one inside.kici/affects the content hash.
Extra files in the content hash (hashFiles)
Section titled “Extra files in the content hash (hashFiles)”A helper the workflow imports from .kici/lib/ is already covered, so editing it invalidates the cache on its own. If your workflow depends on files outside .kici/ — configuration files, scripts, Dockerfiles, etc. — changes to those files will not invalidate the cache unless you declare them.
Use the hashFiles option on a workflow to include additional paths or glob patterns (relative to the repo root) in the content hash:
export default workflow('deploy', { hashFiles: ['config.json', 'scripts/*.sh'], jobs: [/* ... */],});When any of the matched files change, the content hash formula becomes SHA-256(compileSchemaVersion + ":" + treeDigest + "\0" + assetDigest) where assetDigest is a deterministic encoding of the resolved file paths and their contents. This busts the source-tarball cache and forces the build agent to pack and upload a fresh tarball. The resolved file paths are recorded in the lock file under resolvedHashFiles so the agent can verify without re-discovering the workflow.
Agent-side safety net
Section titled “Agent-side safety net”If drift still occurs (e.g. someone committed only the .ts change), the agent detects it at run time before any step runs:
- After extracting the
.kici/source tarball (or loading source from agit cloneon the build path), the agent walks the whole extracted.kici/tree and re-computescontentHash = SHA-256(compileSchemaVersion + ":" + treeDigest [+ "\0" + assetDigest])using the same implementation as the compiler. Because it covers the tree, an edit to any file the workflow imports is caught, not just an edit to the entry file. - If the orchestrator sent a
contentHash(from the lock file) and the computed hash does not match, the agent fails the run with an error like: lock file is out of date (workflow source changed without regenerating the lock file). The error includes the baked agent@kici-dev/sdkversion + bundle hash so operators can debug cross-host compile mismatches. When the hashed tree carries symlinks, the error names them too — recompiling cannot reconcile a link the tarball omits or extraction rewrites, so the usual remedy would loop.
So even without a pre-commit or CI check, a stale lock file will cause the run to fail with a clear message instead of running with the wrong workflow.
Summary
Section titled “Summary”| Goal | What to do |
|---|---|
| Keep lock file in sync | Commit kici.lock.json with the workflow .ts changes; run kici compile before commit. |
| Catch drift before commit | Install a pre-commit hook with kici hook install. |
| Catch broken source in CI | Run kici compile --check in CI. |
| Bust cache on external files | Add hashFiles: ['config.json'] to include non-workflow files in the content hash. |
Skip a path inside .kici/ | List it in .kici/.kiciignore — remember the file replaces the defaults. |
| Fail fast when drift remains | Rely on the agent’s hash verification when it compiles from source. |
See also
Section titled “See also”- Getting started — compile and commit the lock file
- CLI reference —
kici compile,kici compile --check,kici hook - Architecture — Data flows — how the lock file is used in the pipeline