@kici-dev/compiler 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/commands/compile.js +5 -1
  3. package/dist/commands/doctor.js +8 -2
  4. package/dist/commands/init.d.ts +9 -0
  5. package/dist/commands/init.js +77 -12
  6. package/dist/commands/preview.js +1 -1
  7. package/dist/commands/report/identity.d.ts +11 -0
  8. package/dist/commands/report/identity.js +7 -2
  9. package/dist/commands/run-routed.js +1 -0
  10. package/dist/commands/types.d.ts +6 -1
  11. package/dist/commands/types.js +2 -1
  12. package/dist/execution/executor.js +7 -1
  13. package/dist/llm-context/llms-architecture.txt +72 -86
  14. package/dist/llm-context/llms-cli-remote.txt +40 -7
  15. package/dist/llm-context/llms-cli.txt +67 -34
  16. package/dist/llm-context/llms-features-execution.txt +52 -6
  17. package/dist/llm-context/llms-features.txt +137 -6
  18. package/dist/llm-context/llms-full.txt +527 -174
  19. package/dist/llm-context/llms-getting-started.txt +3 -3
  20. package/dist/llm-context/llms-patterns.txt +81 -5
  21. package/dist/llm-context/llms-providers.txt +6 -2
  22. package/dist/llm-context/llms-sdk-runtime.txt +22 -18
  23. package/dist/llm-context/llms-sdk.txt +47 -7
  24. package/dist/llm-context/llms.txt +8 -8
  25. package/dist/local-plane/orchestrator-process.d.ts +0 -8
  26. package/dist/local-plane/orchestrator-process.js +3 -14
  27. package/dist/local-plane/plane-manager.js +2 -2
  28. package/dist/lockfile/generator.js +25 -9
  29. package/dist/lockfile/hasher.d.ts +5 -13
  30. package/dist/lockfile/hasher.js +1 -15
  31. package/dist/lockfile/workspace-siblings.d.ts +46 -0
  32. package/dist/lockfile/workspace-siblings.js +197 -0
  33. package/dist/templates/package-json.js +1 -1
  34. package/dist/test-runner/job-executor.js +1 -1
  35. package/dist/test-runner/rule-evaluator.js +1 -1
  36. package/dist/types.d.ts +6 -1
  37. package/package.json +7 -9
  38. package/sbom.spdx.json +123 -123
  39. package/dist/postinstall.d.ts +0 -9
  40. package/dist/postinstall.js +0 -62
  41. package/hack/postinstall.mjs +0 -105
@@ -48,7 +48,7 @@ The lock file is the seam. Everything left of it is decided once at compile time
48
48
 
49
49
  `kici compile` loads your `.kici/workflows/*.ts`, validates dependencies (no cycles, no missing `needs`), assigns compile-time step IDs (unnamed steps become `step-1`, `step-2`, …), and writes `kici.lock.json`.
50
50
 
51
- The compiler runs your module's **top-level code** to build the workflow object — but that execution's side effects and in-memory state do not travel. Only the resulting workflow structure lands in the lock. Anything your top-level code computes that isn't part of the returned workflow object simply doesn't exist past this point.
51
+ The compiler runs your module's **top-level code** to build the workflow object — but that execution's side effects and in-memory state do not travel. Only the resulting workflow structure lands in the lock. Anything your top-level code computes that isn't part of the returned workflow object doesn't exist past this point.
52
52
 
53
53
  See [compile the workflow](https://docs.kici.dev/user/getting-started/#compile-the-workflow) for the command in context.
54
54
 
@@ -204,7 +204,7 @@ kici runs logs <run-id>
204
204
  When you cannot resolve it yourself, gather a diagnostic bundle:
205
205
 
206
206
  ```bash
207
- kici report --run <run-id> --message "what you expected, and what happened"
207
+ kici report --run <run-id>
208
208
  ```
209
209
 
210
210
  The command writes a ZIP and prints its path and `sha256`. It sends nothing.
@@ -311,7 +311,7 @@ npx kici init
311
311
  This will:
312
312
 
313
313
  1. Create `.kici/` directory with `workflows/`, `tests/`, `types/`, `package.json`, and `tsconfig.json`. The `types/` folder holds a local development aid — TypeScript declarations that `kici types` (and an authenticated `kici compile`) generate from your orchestrator's secret contexts. Its content is a snapshot of one org's secret keys, so it is not committed.
314
- 2. Create a `.kiciignore` file with sensible defaults
314
+ 2. Create two `.kiciignore` files with sensible defaults: one at the repo root, which selects the working-tree files a remote run uploads, and one inside `.kici/`, which declares the paths the per-workflow content hash skips
315
315
  3. Let you choose from starter workflow templates (hello-world, pr-checks)
316
316
  4. Install dependencies using the package manager detected for your repo (npm, pnpm, or yarn)
317
317
  5. Update `.gitignore` to exclude `.kici/node_modules/`, and write `.kici/.gitignore` to keep the generated `types/` declarations untracked (`kici.lock.json` stays tracked — the orchestrator fetches it from your repo)
@@ -1781,6 +1781,74 @@ For a **minted app token**, prefer re-deriving over transporting — those expir
1781
1781
  after an hour, so one minted in an earlier job is often already dead by the time
1782
1782
  a later job reads it. Have the later job name the same secret, or mint its own.
1783
1783
 
1784
+ ## What a job may ask for
1785
+
1786
+ A credential is authorized against the workflow you wrote, not against the code
1787
+ running in the job. Three things must all hold before the orchestrator resolves
1788
+ one:
1789
+
1790
+ 1. **The job declared it.** The orchestrator records the job's `gitCredentials`
1791
+ map when it dispatches the job, and compares every request against that
1792
+ record. A request naming a credential the job did not declare is refused.
1793
+ This is why you pass `credential: 'forge'` — a name — rather than building a
1794
+ credential reference in step code.
1795
+ 2. **The named context admits the run.** A `prod:` reference runs the `prod`
1796
+ context's own protection rules first: its branch restrictions, its
1797
+ `minimumTrust`, its approval requirement. A credential named from a branch
1798
+ the context does not allow is refused, and the git operation fails. The rule
1799
+ that refused it is named in your orchestrator's log, not in the run — the
1800
+ orchestrator returns a fixed error to the job rather than describing its own
1801
+ configuration to code it does not trust.
1802
+ 3. **The contributor is trusted.** A run from an untrusted ref — a fork pull
1803
+ request — gets no declared credential at all. It still clones with the
1804
+ source credential, so the build runs; only the declared credentials are
1805
+ withheld. The reduced-privilege note on the run says so.
1806
+
1807
+ The context in a reference does **not** have to appear in the job's `contexts:`
1808
+ list. The reference names its own context, and that context's rules are what
1809
+ authorize it.
1810
+
1811
+ ## Generated jobs
1812
+
1813
+ A job produced by a `dynamicJob` generator has no entry in the lock file, so it
1814
+ cannot declare credentials of its own. The **generator** declares them, and every
1815
+ job it produces inherits that map:
1816
+
1817
+ ```typescript
1818
+ dynamicJob('shards', {
1819
+ gitCredentials: {
1820
+ forge: { kind: 'token', tokenSecret: 'ci:FORGE_PAT' },
1821
+ },
1822
+ generate: async ({ ctx }) =>
1823
+ ctx.event.payload.targets.map((target) =>
1824
+ job(`publish-${target}`, {
1825
+ runsOn: 'linux',
1826
+ run: async ({ $, repo }) => {
1827
+ await repo.withWrite(
1828
+ { permissions: { contents: 'write' }, credential: 'forge' },
1829
+ async () => {
1830
+ await $`git push origin HEAD`;
1831
+ },
1832
+ );
1833
+ },
1834
+ }),
1835
+ ),
1836
+ });
1837
+ ```
1838
+
1839
+ Three points follow from where the declaration lives:
1840
+
1841
+ - **All generated jobs share one map.** The generator is granted one ceiling, and
1842
+ every job it produces gets exactly that ceiling. Use a second generator when
1843
+ two sets of jobs need different credentials.
1844
+ - **A `gitCredentials` map on a generated job is ignored.** The generator's
1845
+ declaration is committed source that KiCI reads from the lock file. A generated
1846
+ job's own declaration would come from the code that produced it, which is what
1847
+ the authorization check above exists to be independent of.
1848
+ - **The options form is required.** `dynamicJob('shards', async () => …)` — the
1849
+ bare function form — has nowhere to put the declaration. Pass
1850
+ `{ generate, gitCredentials }` instead; `needs` stays optional.
1851
+
1784
1852
  ## How it works, and why long jobs still push
1785
1853
 
1786
1854
  An app token expires an hour after it is issued, and cannot be renewed. Rather
@@ -1797,6 +1865,10 @@ does at the start, and no credential is ever written into `.git/config`, into
1797
1865
  unaffected.
1798
1866
  - **The reserved `needs:` context is not resolvable yet** on a deployed
1799
1867
  orchestrator; naming it produces a clear error rather than a wrong credential.
1868
+ - **A credential reference built in step code is refused.** The SDK takes a
1869
+ credential _name_; there is no way to pass a reference. Code that constructs
1870
+ one and sends it directly is rejected by the agent and, if it reaches the
1871
+ orchestrator, by the declaration check above.
1800
1872
  - **A write window is bounded by the repository and the callback, not the step.**
1801
1873
  Steps running concurrently in the same job can push to the same repository
1802
1874
  while it is open. They cannot reach a different one.
@@ -2131,8 +2203,10 @@ KiCI has no native provider for Gogs, Forgejo, or Gitea, but these forges send H
2131
2203
  ```bash
2132
2204
  # Forgejo / Gitea send event name in X-Gitea-Event and signature in X-Gitea-Signature.
2133
2205
  # Gogs uses X-Gogs-Event and X-Gogs-Signature (same HMAC-SHA256 hex-digest format).
2206
+ # --org must be your Platform organization id: a generic source's routing key
2207
+ # embeds it, and the Platform refuses to register a key naming another org.
2134
2208
  kici-admin source add generic \
2135
- --org my-org \
2209
+ --org <platform-org-id> \
2136
2210
  --name forgejo-main \
2137
2211
  --verification hmac_sha256 \
2138
2212
  --secret @/path/to/webhook-secret.txt \
@@ -2140,7 +2214,7 @@ kici-admin source add generic \
2140
2214
  --rate-limit 120
2141
2215
  ```
2142
2216
 
2143
- Note the returned source ID, then register a webhook in the forge pointing at `https://<platform>/webhooks/<orgId>/generic/<sourceId>` (or the orchestrator's direct URL). Set content type to `application/json` and paste the same secret.
2217
+ Note the returned source ID, then register a webhook in the forge pointing at `https://<platform>/webhook/<orgId>/generic/<sourceId>` (or the orchestrator's direct URL, which takes the source **name** in place of the id). Set content type to `application/json` and paste the same secret.
2144
2218
 
2145
2219
  **Workflow:**
2146
2220
 
@@ -2207,14 +2281,16 @@ HTTPS with a forge PAT works the same way — store the token as a secret, `awai
2207
2281
 
2208
2282
  ## Plain GitHub repo webhooks (no GitHub App)
2209
2283
 
2210
- The Gogs/Forgejo/Gitea pattern above also applies when you want to trigger workflows from a GitHub repository **without installing the KiCI GitHub App** for example because you lack org-admin rights, you're on a restricted GitHub Enterprise tenant, or you simply don't want an App installation. Model the repo-level webhook as a generic source, accepting the same `genericWebhook()`-only ergonomics.
2284
+ The Gogs/Forgejo/Gitea pattern above also applies when you want to trigger workflows from a GitHub repository **without installing the KiCI GitHub App**. You may lack org-admin rights, sit on a restricted GitHub Enterprise tenant, or not want an App installation. Model the repo-level webhook as a generic source, accepting the same `genericWebhook()`-only ergonomics.
2211
2285
 
2212
2286
  **Operator setup:**
2213
2287
 
2214
2288
  ```bash
2215
2289
  # GitHub sends event name in X-GitHub-Event and HMAC-SHA256 signature in X-Hub-Signature-256.
2290
+ # --org must be your Platform organization id: a generic source's routing key
2291
+ # embeds it, and the Platform refuses to register a key naming another org.
2216
2292
  kici-admin source add generic \
2217
- --org my-org \
2293
+ --org <platform-org-id> \
2218
2294
  --name gh-repo-foo \
2219
2295
  --verification hmac_sha256 \
2220
2296
  --secret @/path/to/webhook-secret.txt \
@@ -2231,7 +2307,7 @@ curl -X PATCH https://<orchestrator>/api/v1/admin/generic-sources/<sourceId> \
2231
2307
 
2232
2308
  Then in the GitHub repo, go to **Settings → Webhooks → Add webhook**, set:
2233
2309
 
2234
- - **Payload URL:** `https://<platform>/webhooks/<orgId>/generic/<sourceId>` (or the orchestrator's direct URL)
2310
+ - **Payload URL:** `https://<platform>/webhook/<orgId>/generic/<sourceId>` (or the orchestrator's direct URL, which takes the source **name** in place of the id)
2235
2311
  - **Content type:** `application/json`
2236
2312
  - **Secret:** the same secret
2237
2313
  - **Events:** pick what you care about (e.g., `push`, `pull_request`)
@@ -2688,6 +2764,7 @@ runsOn: { labels: ['kici:os:linux'], exclude: ['kici:host:box-01'] }
2688
2764
 
2689
2765
  - **Required labels:** The agent must have every label in the `labels` array (or the string/array form).
2690
2766
  - **Excluded labels:** The agent must NOT have any label in the `exclude` array. This includes auto-derived labels like `kici:arch:arm64`, `kici:os:linux`, etc.
2767
+ - **Case:** Label matching is **case-insensitive** at every step. `runsOn: 'gpu'` matches an agent that reports `GPU`, and a pool declaring `["Docker"]` serves a `runsOn: ["docker"]` job (see [auto-scaler matching rules](https://docs.kici.dev/operator/orchestrator/auto-scaler/operations/#matching-rules)). KiCI stores and displays every label in lowercase, so the dashboard, `kici-admin agent list`, and `ctx.kici.inventory[…].labels` report the folded form. Compare against a lowercase value when you read a label back in workflow code: `h.labels.includes('gpu')`, not `h.labels.includes('GPU')`.
2691
2768
  - **Compile-time validation:** The compiler will error if any label appears in both `labels` and `exclude` (overlap detection).
2692
2769
  - **Operator-declared mandatory labels:** Operators may mark a scaler with `mandatoryLabels` (Kubernetes-taint-style opt-in). When a scaler declares a mandatory label, a job is only allowed to land on it if `runsOn.labels` includes that label. A workflow targeting such a scaler must explicitly list the mandatory label in `runsOn`. See the [auto-scaler mandatory labels](https://docs.kici.dev/operator/orchestrator/auto-scaler/common-config/#mandatory--exclude-labels) for details.
2693
2770
 
@@ -2740,6 +2817,12 @@ Every selector element — in `runsOn`, in `runsOnAll`, on both the include and
2740
2817
  - **String with glob metacharacters (`*`, `?`, `[]`, `{}`) → glob.** `'kici:host:web-*'` matches every host label starting with `kici:host:web-`. `'kici:host:box-0[1-3]'` matches `box-01`, `box-02`, `box-03`.
2741
2818
  - **`RegExp` literal → regular expression.** `/kici:host:box-0[1-3]/` matches any label the expression matches.
2742
2819
 
2820
+ All three forms match case-insensitively. `'GPU'`, `'kici:host:Web-*'` and `/kici:host:BOX-0[1-3]/` each match a label of any case, and a `RegExp` you write with the `i` flag behaves the same. The `g` and `y` flags are dropped — a selector asks one question per label, so a sticky match would resume part-way through the next one.
2821
+
2822
+ `kici:host:` carries the machine's hostname folded to lowercase. A host that calls itself `Build-Box-01` advertises `kici:host:build-box-01`, and both `runsOn: 'kici:host:build-box-01'` and `runsOn: 'kici:host:Build-Box-01'` match it.
2823
+
2824
+ Case folding covers labels and hostnames only. An **agent ID** stays an opaque identifier and compares exactly, which is what keeps a per-host secret binding on `prod-01` away from an agent named `PROD-01` — see [per-host secret scoping](https://docs.kici.dev/operator/security/secrets/#per-host-secret-scoping).
2825
+
2743
2826
  Both the required (include) side and the excluded side accept all three forms:
2744
2827
 
2745
2828
  ```typescript
@@ -3398,11 +3481,19 @@ Tag a dynamic job generator with a group name so other jobs can reference it via
3398
3481
  ```typescript
3399
3482
  function dynamicJob(
3400
3483
  groupName: string,
3401
- fnOrConfig: DynamicJobFn | { needs: DynamicJobNeed[]; generate: DynamicJobFn },
3484
+ fnOrConfig:
3485
+ | DynamicJobFn
3486
+ | {
3487
+ needs?: DynamicJobNeed[];
3488
+ generate: DynamicJobFn;
3489
+ gitCredentials?: GitCredentialMap;
3490
+ },
3402
3491
  ): TaggedDynamicJobFn;
3403
3492
  ```
3404
3493
 
3405
- The second argument is either a plain generator (event-only, evaluated at webhook time) or a result-aware `{ needs, generate }` config, which defers the generator until its declared upstreams complete and exposes their frozen outputs as `ctx.needs`. See [Rules, matrix, dynamic jobs](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#dynamicjob--result-aware-generation) for the result-aware form.
3494
+ The second argument is either a plain generator (event-only, evaluated at webhook time) or an options config. An options config that declares `needs` is result-aware: it defers the generator until those upstreams complete and exposes their frozen outputs as `ctx.needs`. See [Rules, matrix, dynamic jobs](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#dynamicjob--result-aware-generation) for the result-aware form.
3495
+
3496
+ `needs` is optional. An options config without it is evaluated at webhook time, like the plain generator form. Use that form to declare `gitCredentials` on an event-only generator: every job the generator produces inherits the map, which is the only way a generated job gets named credentials. See [Git credentials](https://docs.kici.dev/user/patterns/git-credentials/#generated-jobs).
3406
3497
 
3407
3498
  The generator runs twice: once in the init phase (to register expected job names) and once inside the executing agent (to produce the actual jobs). Mismatches between the two evaluations are detected as determinism drift -- see [dynamic-jobs](https://docs.kici.dev/architecture/execution/dynamic-jobs/).
3408
3499
 
@@ -3581,6 +3672,11 @@ The throw is a `ChangedFilesUnavailableError` (exported from `@kici-dev/sdk`, ca
3581
3672
 
3582
3673
  ### evaluateRules(rules, context, label, onRuleResult?)
3583
3674
 
3675
+ The agent calls this on your behalf. A workflow does not call it. It lives on
3676
+ `@kici-dev/sdk/internal`, outside semver, and stays exported from the root barrel as
3677
+ `@deprecated` until v1.0.0 — see [deprecations](https://docs.kici.dev/user/deprecations/). It is described here
3678
+ because its return shape is what a rule's outcome looks like in the run log.
3679
+
3584
3680
  Evaluate an array of rules sequentially with fail-fast behavior. Stops on the first failure.
3585
3681
 
3586
3682
  ```typescript
@@ -3648,7 +3744,7 @@ Every variant carries the shared `EventBase` fields — `type`, `action`, `targe
3648
3744
 
3649
3745
  Matrix configurations expand a single job into multiple instances, one per parameter combination. Maximum 256 combinations.
3650
3746
 
3651
- Combinations must be **unique**. Two combinations that would produce the same instance name — most simply, the same value listed twice — fail the job instead of quietly running it twice.
3747
+ Combinations must be **unique**. Two combinations that would produce the same instance name — in the simplest case, the same value listed twice — fail the job instead of quietly running it twice.
3652
3748
 
3653
3749
  Expansion happens at **dispatch time**: the orchestrator materializes the matrix into N execution jobs — one per combination, each dispatched to its own agent — before any job runs. Each instance receives its combination as `ctx.matrix`. This is identical whether the workflow runs via `kici run <event> --local` or remotely through a webhook trigger, and the dashboard groups the N instances under one parent node.
3654
3750
 
@@ -3831,8 +3927,13 @@ isDynamicFunction(matrix); // true if async function
3831
3927
 
3832
3928
  ### Matrix expansion utilities
3833
3929
 
3930
+ The agent expands a matrix for you. A workflow does not call these. They live on
3931
+ `@kici-dev/sdk/internal`, outside semver, and stay exported from the root barrel as
3932
+ `@deprecated` until v1.0.0 — see [deprecations](https://docs.kici.dev/user/deprecations/). They are described
3933
+ here because they define the combinations a matrix job actually produces.
3934
+
3834
3935
  ```typescript
3835
- import { expandMatrix, applyIncludeExclude } from '@kici-dev/sdk';
3936
+ import { expandMatrix, applyIncludeExclude } from '@kici-dev/sdk/internal';
3836
3937
  ```
3837
3938
 
3838
3939
  `expandMatrix(matrix)` takes a string array or an object of string arrays and returns all combinations as `MatrixValues[]`. For a single-dimension array, each value becomes `{ value: '...' }`. For multi-dimensional objects, it produces the Cartesian product. Anything else throws a `MatrixShapeError` naming the expected shape; numbers and booleans inside the values are accepted and converted to strings.
@@ -3890,7 +3991,8 @@ export default workflow('ci', {
3890
3991
  `dynamicJob(group, fnOrConfig)` tags a generator with a group name (so static jobs can depend on it via `needs: [dynamicGroup('group')]`). It is polymorphic:
3891
3992
 
3892
3993
  - **Function form** — event-only, dispatched at webhook time: `dynamicJob('shards', async ({ ctx }) => [...])`.
3893
- - **Options-object form** — result-aware, deferred until its declared `needs` complete, then run with the upstreams' frozen outputs as `ctx.needs`: `dynamicJob('reports', { needs, generate })`.
3994
+ - **Options-object form** — `dynamicJob('reports', { needs, generate })`. With `needs`, it is result-aware: deferred until those upstreams complete, then run with their frozen outputs as `ctx.needs`.
3995
+ - `needs` is optional. Without it the generator is dispatched at webhook time, like the function form. That form is how a generator declares `gitCredentials`, which every job it produces inherits — see [Git credentials](https://docs.kici.dev/user/patterns/git-credentials/#generated-jobs).
3894
3996
 
3895
3997
  ```typescript
3896
3998
  import { workflow, job, step, dynamicJob, dynamicGroup, z } from '@kici-dev/sdk';
@@ -4063,7 +4165,7 @@ those files contain**. It is a declarative filter on the `pr()`, `push()`, and
4063
4165
  at the event's commit. The orchestrator evaluates it as pure data before
4064
4166
  dispatching — it reads only the referenced files, never clones the whole
4065
4167
  repository, and never runs any of your workflow code. A workflow whose `requires`
4066
- does not pass is simply not dispatched.
4168
+ does not pass is not dispatched.
4067
4169
 
4068
4170
  Each entry is a `ContentRequirement`:
4069
4171
 
@@ -4328,7 +4430,7 @@ dispatch({ types: ['deploy', 'rollback'] }); // Specific event types
4328
4430
  #### Typed dispatch inputs
4329
4431
 
4330
4432
  A `dispatch()` trigger can declare a typed `inputs` schema. Operators supply
4331
- values with `kici run --input key=value`; KiCI validates, coerces, defaults, and
4433
+ values with `kici run remote --input key=value`; KiCI validates, coerces, defaults, and
4332
4434
  exposes them to steps and rules as `ctx.dispatchInputs`. The values are validated
4333
4435
  on the orchestrator from the compiled lock file — a missing required input or a
4334
4436
  bad value is rejected before any agent runs, without cloning the repository.
@@ -5043,6 +5145,20 @@ import { workflow, job, step, pr, push, rule, defineEvent } from '@kici-dev/sdk'
5043
5145
 
5044
5146
  For the complete list of every named export (factory functions, triggers, rules, validation, hook factories, types), see the per-topic pages above.
5045
5147
 
5148
+ ## `@kici-dev/sdk/internal` is not a supported surface
5149
+
5150
+ The package also publishes an `@kici-dev/sdk/internal` subpath. It carries the runtime
5151
+ contract between the SDK and the KiCI agent. Those are the functions that install the maps
5152
+ a `.result` proxy reads, build the step context your workflow body receives, evaluate its
5153
+ rules, and expand its matrix. The agent drives all of it on your behalf.
5154
+
5155
+ It is **not covered by semver** and may change shape in any release. Do not import it from
5156
+ a workflow. Everything a workflow author needs is on the root entry point above.
5157
+
5158
+ Those same symbols are also still exported from the root barrel, marked `@deprecated`, so
5159
+ an older SDK in a repository keeps working. They are removed from the root at v1.0.0 — see
5160
+ [deprecations](https://docs.kici.dev/user/deprecations/).
5161
+
5046
5162
  ## See also
5047
5163
 
5048
5164
  - [Getting started](https://docs.kici.dev/user/getting-started/) -- install the SDK, write your first workflow, test locally
@@ -5671,7 +5787,7 @@ The SDK exposes three idempotency helpers — a generic function `idempotent()`,
5671
5787
  2. **Apply** the change only when drift is detected.
5672
5788
  3. **Surface** the resource (or its identifier) on both branches, so downstream steps don't need to know whether work happened or was skipped.
5673
5789
 
5674
- `idempotent()` and `idempotentStep()` wrap the same underlying runner and always apply on drift. Pick `idempotentStep()` when the operation is the whole job of a step; use `idempotent()` from anywhere — inside a multi-action step, a hook, or a bare async function. Pick `checkStep()` when the step should respect the run-level check mode — `kici run --check` previews the drift without applying it.
5790
+ `idempotent()` and `idempotentStep()` wrap the same underlying runner and always apply on drift. Pick `idempotentStep()` when the operation is the whole job of a step; use `idempotent()` from anywhere — inside a multi-action step, a hook, or a bare async function. Pick `checkStep()` when the step should respect the run-level check mode — `kici run remote --check` previews the drift without applying it.
5675
5791
 
5676
5792
  ## `idempotent(options)`
5677
5793
 
@@ -5766,30 +5882,30 @@ export const setup = job('setup', {
5766
5882
 
5767
5883
  ## `checkStep(name, options)`
5768
5884
 
5769
- The check-mode-aware sibling of `idempotentStep()`. It takes a closely related option shape, but behaves differently when a run is started in check mode (`kici run --check`):
5885
+ The check-mode-aware sibling of `idempotentStep()`. It takes a closely related option shape, but behaves differently when a run is started in check mode (`kici run remote --check`):
5770
5886
 
5771
- | Factory | Behavior under `kici run --check` |
5887
+ | Factory | Behavior under `kici run remote --check` |
5772
5888
  | ---------------- | ----------------------------------------- |
5773
5889
  | `idempotentStep` | always applies on drift |
5774
5890
  | `checkStep` | reports drift, applies only in apply mode |
5775
5891
 
5776
- Use `checkStep()` for deploy-style steps where you want a dry-run preview of pending changes before committing them, and `idempotentStep()` for steps that must always converge (for example inside a hook). A `checkStep()` desugars to the first-class step check facet (`check` / `summarize` / `run(ctx, drift)` / `whenInSync`), so it participates in run-level check mode automatically: `kici run --check` reports the drift and skips `apply`, `kici run --check --fail-on-drift` exits non-zero when drift is detected, and apply mode applies the change.
5892
+ Use `checkStep()` for deploy-style steps where you want a dry-run preview of pending changes before committing them, and `idempotentStep()` for steps that must always converge (for example inside a hook). A `checkStep()` desugars to the first-class step check facet (`check` / `summarize` / `run(ctx, drift)` / `whenInSync`), so it participates in run-level check mode automatically: `kici run remote --check` reports the drift and skips `apply`, `kici run remote --check --fail-on-drift` exits non-zero when drift is detected, and apply mode applies the change.
5777
5893
 
5778
5894
  ### Parameters
5779
5895
 
5780
- | Name | Type | Required | Description |
5781
- | ----------------- | ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
5782
- | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
5783
- | `check` | `(ctx) => Promise<TDrift \| null>` | Yes | Read-only inspection. Return `null` when the system is already in the desired state. |
5784
- | `apply` | `(ctx, drift: TDrift) => Promise<TApplied>` | Yes | Brings the system to the desired state. Runs only in apply mode (skipped under `kici run --check`). |
5785
- | `summarize` | `(drift: TDrift) => string` | Yes | Human-readable summary of what `apply()` would do; shown in check-mode drift output. |
5786
- | `whenInSync` | `(ctx) => Promise<TInSync>` | No | Runs when `check()` returned `null` (already in sync). |
5787
- | `outputs` | `OutputSchema` | No | Zod schema validating the step's outputs at runtime. |
5788
- | `continueOnError` | `boolean` | No | When true, the job proceeds even if this step fails. |
5789
- | `timeout` | `number` | No | Step-level timeout in milliseconds. |
5790
- | `retry` | `number \| RetryConfig` | No | Retry policy for the step; `retry: N` is shorthand for `{ maxAttempts: N }`. |
5791
- | `cache` | `CacheInput` | No | Declarative cache restored before the step and saved after it succeeds. |
5792
- | `rules` | `Rule[]` | No | Step-level conditional rules, evaluated agent-side. |
5896
+ | Name | Type | Required | Description |
5897
+ | ----------------- | ------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
5898
+ | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
5899
+ | `check` | `(ctx) => Promise<TDrift \| null>` | Yes | Read-only inspection. Return `null` when the system is already in the desired state. |
5900
+ | `apply` | `(ctx, drift: TDrift) => Promise<TApplied>` | Yes | Brings the system to the desired state. Runs only in apply mode (skipped under `kici run remote --check`). |
5901
+ | `summarize` | `(drift: TDrift) => string` | Yes | Human-readable summary of what `apply()` would do; shown in check-mode drift output. |
5902
+ | `whenInSync` | `(ctx) => Promise<TInSync>` | No | Runs when `check()` returned `null` (already in sync). |
5903
+ | `outputs` | `OutputSchema` | No | Zod schema validating the step's outputs at runtime. |
5904
+ | `continueOnError` | `boolean` | No | When true, the job proceeds even if this step fails. |
5905
+ | `timeout` | `number` | No | Step-level timeout in milliseconds. |
5906
+ | `retry` | `number \| RetryConfig` | No | Retry policy for the step; `retry: N` is shorthand for `{ maxAttempts: N }`. |
5907
+ | `cache` | `CacheInput` | No | Declarative cache restored before the step and saved after it succeeds. |
5908
+ | `rules` | `Rule[]` | No | Step-level conditional rules, evaluated agent-side. |
5793
5909
 
5794
5910
  Everything from `outputs` down is a plain [`step()` option](https://docs.kici.dev/user/sdk/core/) forwarded to the underlying step. The three step options `checkStep` does **not** accept are `onCancel`, `cleanup`, and `approval`.
5795
5911
 
@@ -5823,7 +5939,7 @@ export const deploy = job('deploy', {
5823
5939
  });
5824
5940
  ```
5825
5941
 
5826
- Run `kici run --check` against this workflow to see the drift summary without touching DNS; run it without `--check` to apply.
5942
+ Run `kici run remote --check` against this workflow to see the drift summary without touching DNS; run it without `--check` to apply.
5827
5943
 
5828
5944
  ## Worked example: create-if-missing returning a resource id
5829
5945
 
@@ -6586,6 +6702,8 @@ const deploy = job('deploy', {
6586
6702
 
6587
6703
  Request a short-lived OIDC ID token for the current job, bound to an `audience`. The token is a signed JWT whose identity claims (`repository`, `ref`, `sha`, `kici_run_id`, `kici_job_id`) are derived by your orchestrator from the run context — a step cannot spoof them. Use it to authenticate the build to an external service that trusts the orchestrator's OIDC issuer (for example, when generating build provenance).
6588
6704
 
6705
+ The token also carries the event context a cloud trust policy needs to tell a fork pull request from a trusted push — `is_fork`, `head_repository`, `trust_tier`, `event_name`, and a pull-request-specific `sub`. See [ID-token claims and cloud trust policies](https://docs.kici.dev/user/provenance/#id-token-claims-and-cloud-trust-policies) for the full claim table and a worked AWS policy.
6706
+
6589
6707
  ```typescript
6590
6708
  const publish = job('publish', {
6591
6709
  steps: [
@@ -6625,6 +6743,8 @@ interface HostInventoryEntry {
6625
6743
 
6626
6744
  Two dimensions describe a host. **Labels** are flat strings used for grouping and targeting (the same labels `runsOn` / `runsOnAll` match). **Properties** are typed host-vars (`string | number | boolean`) — the place for facts like `region`, `cores`, or `gpu`. A host reports its own properties via the agent's `KICI_PROPERTIES` config, and an operator can pre-declare them with `kici-admin host declare --prop key=value`; the two are shallow-merged (agent-reported keys win).
6627
6745
 
6746
+ `labels` and `hostname` come back lowercase — KiCI folds both, so a pool declaring `Docker` reports `docker`. Compare against a lowercase value: `h.labels.includes('gpu')`, not `h.labels.includes('GPU')`. A label selector passed to `query()` folds too, so `{ include: [['GPU']] }` matches. `agentId` and `properties` keep their case.
6747
+
6628
6748
  ```typescript
6629
6749
  // All hosts:
6630
6750
  const all = await ctx.kici.inventory.query();
@@ -7320,7 +7440,7 @@ triggered, or the repo had no lock file at that commit.
7320
7440
  source-registration mismatch).
7321
7441
  2. **Did anything match?** Run `kici preview push --branch <your-branch>`
7322
7442
  against your workflow. If it reports no matching workflow, your triggers don't
7323
- cover that event/branch — the push was delivered and simply matched nothing.
7443
+ cover that event/branch — the push was delivered and matched nothing.
7324
7444
  3. **Was there a lock file?** A repository with **no** `kici.lock.json` at the
7325
7445
  pushed commit produces no run and is not an error. Confirm the lock file is
7326
7446
  committed and current (see [Lock-file drift](https://docs.kici.dev/user/common-failures/#lock-file-drift)).
@@ -7639,7 +7759,7 @@ KiCI uses a **two-artifact model**: TypeScript workflows are the source of truth
7639
7759
  ## Why the lock file matters
7640
7760
 
7641
7761
  - **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_modules` tarball. It never runs your TypeScript.
7642
- - **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 `.ts` directly. The lock file's per-workflow `contentHash` identifies the expected raw-source bytes and is verified against the extracted source before any step runs.
7762
+ - **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 `.ts` directly. The lock file's per-workflow `contentHash` identifies 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.
7643
7763
 
7644
7764
  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.
7645
7765
 
@@ -7647,37 +7767,38 @@ If you change a workflow file (`.ts`) but do **not** regenerate and commit the l
7647
7767
 
7648
7768
  The lock file (`kici.lock.json`) is a JSON file with the following top-level fields:
7649
7769
 
7650
- | Field | Description |
7651
- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7652
- | `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](https://docs.kici.dev/user/lock-file-and-drift/#schema-compatibility-window) — rather than requiring an exact match. |
7653
- | `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. Omitted on locks compiled before the compatibility window existed. See [schema compatibility window](https://docs.kici.dev/user/lock-file-and-drift/#schema-compatibility-window). |
7654
- | `source` | Reference to the source file and export (e.g., `{ file: '.kici/workflows/ci.ts', export: '#default' }`). |
7655
- | `contentHash` | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. |
7656
- | `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, 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. |
7657
- | `workflows` | Array of workflow entries, each with its own `contentHash`, `compileSchemaVersion`, triggers, and jobs. |
7770
+ | Field | Description |
7771
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
7772
+ | `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](https://docs.kici.dev/user/lock-file-and-drift/#schema-compatibility-window) — rather than requiring an exact match. |
7773
+ | `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. Omitted on locks compiled before the compatibility window existed. See [schema compatibility window](https://docs.kici.dev/user/lock-file-and-drift/#schema-compatibility-window). |
7774
+ | `source` | Reference to the source file and export (e.g., `{ file: '.kici/workflows/ci.ts', export: '#default' }`). |
7775
+ | `contentHash` | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. |
7776
+ | `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, 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. |
7777
+ | `siblingsDigest` | SHA-256 over the git-tracked source of every in-repo `workspace:` / `file:` / `link:` / `portal:` sibling package `.kici` depends on, transitively. 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. |
7778
+ | `workflows` | Array of workflow entries, each with its own `contentHash`, `compileSchemaVersion`, triggers, and jobs. |
7658
7779
 
7659
7780
  Each workflow entry includes:
7660
7781
 
7661
- | Field | Description |
7662
- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7663
- | `name` | Workflow name. |
7664
- | `source` | Per-workflow source file and export reference. |
7665
- | `contentHash` | SHA-256 of the raw workflow source mixed with `compileSchemaVersion` (and an `assetDigest` of declared `hashFiles` when present): `SHA-256(compileSchemaVersion + ":" + rawSource [+ "\0" + assetDigest])`. The orchestrator uses this as the source-tarball cache key and the agent re-computes it against the extracted source to detect drift. |
7666
- | `compileSchemaVersion` | Compiler schema version used when computing `contentHash` (currently `5`). 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. |
7667
- | `triggers` | Trigger definitions extracted from the workflow (used by the orchestrator for event matching). |
7668
- | `jobs` | Job definitions with scheduling metadata (runsOn, needs, matrix, contexts, concurrency, container, checkout, gracePeriod, label routing, dynamic fields, etc.). |
7669
- | `rules` | Workflow-level conditional rules (optional). Stored as dynamic references since rule functions cannot be serialized. |
7670
- | `description` | Optional workflow description. |
7671
- | `hashFiles` | Declared glob patterns for extra files included in the content hash (optional). See [extra files in the content hash](https://docs.kici.dev/user/lock-file-and-drift/#extra-files-in-the-content-hash-hashfiles). |
7672
- | `resolvedHashFiles` | Resolved file paths from `hashFiles` at compile time (optional). Recorded so the agent can verify without re-discovering. |
7673
- | `contexts` | Secret contexts declared by the workflow (optional). The orchestrator validates access to each context before dispatch. |
7674
- | `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](https://docs.kici.dev/user/private-registries/). |
7675
- | `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](https://docs.kici.dev/user/private-registries/). |
7676
- | `concurrency` | Workflow-level concurrency config: `hasGroup`, `cancelInProgress`, `max` (optional). See [concurrency groups](https://docs.kici.dev/user/concurrency/). |
7677
- | `timeout` | Whole-run wall-clock timeout in milliseconds (optional). The orchestrator reads this at run creation to set the run deadline. |
7678
- | `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](https://docs.kici.dev/user/approvals/). |
7679
- | `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](https://docs.kici.dev/user/global-workflows/#narrowing-with-a-filter). |
7680
- | Hook flags | Boolean flags (`hasOnCancel`, `hasCleanup`, `hasOnSuccess`, `hasOnFailure`) indicating which lifecycle hooks are defined. Job entries additionally have `hasBeforeStep` and `hasAfterStep`. |
7782
+ | Field | Description |
7783
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7784
+ | `name` | Workflow name. |
7785
+ | `source` | Per-workflow source file and export reference. |
7786
+ | `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](https://docs.kici.dev/user/lock-file-and-drift/#files-the-content-hash-skips-kicikiciignore). 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. |
7787
+ | `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. |
7788
+ | `triggers` | Trigger definitions extracted from the workflow (used by the orchestrator for event matching). |
7789
+ | `jobs` | Job definitions with scheduling metadata (runsOn, needs, matrix, contexts, concurrency, container, checkout, gracePeriod, label routing, dynamic fields, etc.). |
7790
+ | `rules` | Workflow-level conditional rules (optional). Stored as dynamic references since rule functions cannot be serialized. |
7791
+ | `description` | Optional workflow description. |
7792
+ | `hashFiles` | Declared glob patterns for extra files included in the content hash (optional). See [extra files in the content hash](https://docs.kici.dev/user/lock-file-and-drift/#extra-files-in-the-content-hash-hashfiles). |
7793
+ | `resolvedHashFiles` | Resolved file paths from `hashFiles` at compile time (optional). Recorded so the agent can verify without re-discovering. |
7794
+ | `contexts` | Secret contexts declared by the workflow (optional). The orchestrator validates access to each context before dispatch. |
7795
+ | `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](https://docs.kici.dev/user/private-registries/). |
7796
+ | `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](https://docs.kici.dev/user/private-registries/). |
7797
+ | `concurrency` | Workflow-level concurrency config: `hasGroup`, `cancelInProgress`, `max` (optional). See [concurrency groups](https://docs.kici.dev/user/concurrency/). |
7798
+ | `timeout` | Whole-run wall-clock timeout in milliseconds (optional). The orchestrator reads this at run creation to set the run deadline. |
7799
+ | `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](https://docs.kici.dev/user/approvals/). |
7800
+ | `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](https://docs.kici.dev/user/global-workflows/#narrowing-with-a-filter). |
7801
+ | Hook flags | Boolean flags (`hasOnCancel`, `hasCleanup`, `hasOnSuccess`, `hasOnFailure`) indicating which lifecycle hooks are defined. Job entries additionally have `hasBeforeStep` and `hasAfterStep`. |
7681
7802
 
7682
7803
  Step entries carry their own capability flags, so the orchestrator can reason about a step without loading your TypeScript:
7683
7804
 
@@ -7758,9 +7879,40 @@ kici compile --check
7758
7879
 
7759
7880
  This validates all workflows and generates the lock file in memory without writing it. If any workflow has syntax 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.
7760
7881
 
7882
+ ## Files the content hash skips (`.kici/.kiciignore`)
7883
+
7884
+ 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:
7885
+
7886
+ ```
7887
+ node_modules/
7888
+ types/
7889
+ .npmrc
7890
+ package-lock.json
7891
+ pnpm-lock.yaml
7892
+ kici.lock.json
7893
+ ```
7894
+
7895
+ Every 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.
7896
+
7897
+ `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.
7898
+
7899
+ 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/`.
7900
+
7901
+ 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.
7902
+
7903
+ 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`.
7904
+
7905
+ :::caution[The file replaces the defaults — it does not add to them]
7906
+ When `.kici/.kiciignore` exists, it **is** the exclusion list. A one-line file excludes one path and re-includes everything else, `package-lock.json` included. `kici compile` warns when your file omits a path a run rewrites, and names both the path and the instability it causes. Delete the file to fall back to the defaults.
7907
+ :::
7908
+
7909
+ `.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.
7910
+
7911
+ > **Not the repo-root `.kiciignore`.** A `.kiciignore` at the root of your repository is a separate, unrelated file: it selects which working-tree files `kici run remote` uploads. Only the one inside `.kici/` affects the content hash.
7912
+
7761
7913
  ## Extra files in the content hash (`hashFiles`)
7762
7914
 
7763
- By default, the per-workflow content hash is `SHA-256(compileSchemaVersion + ":" + rawSource)` where `rawSource` is the TypeScript text of the workflow entry file. If your workflow depends on files outside `.kici/workflows/` -- configuration files, scripts, Dockerfiles, etc. -- changes to those files will **not** invalidate the cache unless you declare them.
7915
+ 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.
7764
7916
 
7765
7917
  Use the `hashFiles` option on a workflow to include additional paths or glob patterns (relative to the repo root) in the content hash:
7766
7918
 
@@ -7771,14 +7923,14 @@ export default workflow('deploy', {
7771
7923
  });
7772
7924
  ```
7773
7925
 
7774
- When any of the matched files change, the content hash formula becomes `SHA-256(compileSchemaVersion + ":" + rawSource + "\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 `source/{contentHash}.tar.gz`. The resolved file paths are recorded in the lock file under `resolvedHashFiles` so the agent can verify without re-discovering the workflow.
7926
+ 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.
7775
7927
 
7776
7928
  ## Agent-side safety net
7777
7929
 
7778
7930
  If drift still occurs (e.g. someone committed only the `.ts` change), the agent detects it at run time before any step runs:
7779
7931
 
7780
- - After extracting the `.kici/` source tarball (or loading source from a `git clone` on the build path), the agent reads the workflow entry file and re-computes `contentHash = SHA-256(compileSchemaVersion + ":" + rawSource [+ "\0" + assetDigest])` using the same formula as the compiler.
7781
- - 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/sdk` version + bundle hash so operators can debug cross-host compile mismatches.
7932
+ - After extracting the `.kici/` source tarball (or loading source from a `git clone` on the build path), the agent walks the whole extracted `.kici/` tree and re-computes `contentHash = 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.
7933
+ - 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/sdk` version + 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.
7782
7934
 
7783
7935
  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.
7784
7936
 
@@ -7790,6 +7942,7 @@ So even without a pre-commit or CI check, a stale lock file will cause the run t
7790
7942
  | Catch drift before commit | Install a pre-commit hook with `kici hook install`. |
7791
7943
  | Catch broken source in CI | Run `kici compile --check` in CI. |
7792
7944
  | Bust cache on external files | Add `hashFiles: ['config.json']` to include non-workflow files in the content hash. |
7945
+ | Skip a path inside `.kici/` | List it in `.kici/.kiciignore` — remember the file replaces the defaults. |
7793
7946
  | Fail fast when drift remains | Rely on the agent’s hash verification when it compiles from source. |
7794
7947
 
7795
7948
  ## See also
@@ -8959,6 +9112,12 @@ If you belong to a single organization, the org is resolved automatically. If
8959
9112
  you belong to several, pass an `orgId` argument to any tool (use `list_orgs` to
8960
9113
  find it).
8961
9114
 
9115
+ Only an **active** membership counts: an organization you have been suspended
9116
+ in, one that has been disabled, and one that has been deleted are all skipped.
9117
+ So a single active membership alongside a disabled one still resolves
9118
+ automatically, and a tool call against an organization you are suspended in is
9119
+ refused with the same message the dashboard gives.
9120
+
8962
9121
  ### Limits and pagination
8963
9122
 
8964
9123
  The MCP server applies a few bounds so an agent loop can't overwhelm the shared
@@ -9048,7 +9207,7 @@ you hold, so an agent cannot escalate beyond its creator.
9048
9207
 
9049
9208
  **Repository scope comes along too.** If your role is restricted to a set of
9050
9209
  repositories, an agent token you mint is restricted to the same set. Runs
9051
- outside it are simply not there: they are filtered out of `list_runs`,
9210
+ outside it are not there: they are filtered out of `list_runs`,
9052
9211
  `cancel_runs_by_branch` skips them, and naming one directly answers "not found"
9053
9212
  — the same answer a run id that does not exist gets, so an agent cannot use the
9054
9213
  tools to discover which repositories it is missing. Your organization's audit
@@ -9291,14 +9450,18 @@ Tokens authenticate; RBAC authorizes. Every org-scoped route runs `orgContextMid
9291
9450
 
9292
9451
  ### Configurable surfaces
9293
9452
 
9294
- The dashboard is a browser SPA on top of the same `/api/v1/*` surface, so anything you can configure in the dashboard you can configure over HTTP. The mounted route groups include:
9453
+ The dashboard is a browser SPA on top of the same `/api/v1/*` surface, so nearly everything you can configure in the dashboard you can configure over HTTP. The exception is a short list of routes that only a **browser session** may call, marked † below. The mounted route groups include:
9295
9454
 
9296
- - **Auth & identity:** `/cli/exchange-token`, `/pats`, `/user`, `/identity-links`, `/github-oauth`, `/invites`, `/invites/pending`, `/invites/:inviteId/{accept,decline}`
9297
- - **Org & membership:** `/orgs`, `/orgs/:customerId`, `/orgs/:customerId/{members,roles,api-keys,orchestrator-keys,service-accounts,billing,trust-policies}`
9455
+ - **Auth & identity:** `/cli/exchange-token`†, `/pats`, `/user`, `/identity-links`, `/identity-links/:id` (DELETE)†, `/github-oauth`, `/auth/github/link`†, `/invites`, `/invites/pending`, `/invites/:inviteId/{accept,decline}`†
9456
+ - **Org & membership:** `/orgs` (POST)†, `/orgs/:customerId`, `/orgs/:customerId/{members,roles,api-keys,orchestrator-keys,service-accounts,billing,trust-policies}`
9298
9457
  - **Workflows & runs:** `/orgs/:customerId/{runs,registrations,workflows,held-runs,contexts,secrets,global-workflows}`
9299
9458
  - **Webhooks & event log:** `/orgs/:customerId/{sources,webhook-endpoints,event-log}`
9300
9459
  - **Diagnostics & activity:** `/orgs/:customerId/{diagnostics,activity,access-log}`
9301
9460
 
9461
+ **† Browser session only.** These routes answer **403** with `This endpoint requires an interactive login` to a `kici_pat_`, `kici_sk_` or `kici_sa_` token. They are the routes that create an organization, join or decline one, unlink a provider identity, start a GitHub account link, and exchange your session for a personal access token. Each mints a credential wider than the credential presenting it, or changes which organizations and provider identities your account reaches. A `kici_sk_` or `kici_sa_` token is bound to one organization and one permission set, so letting it take those actions would hand it access it was never granted. Do them in the dashboard, or with `kici login`, which runs the browser flow for you.
9462
+
9463
+ `POST /pats` is the one route in between: it accepts a browser session **or** an existing `kici_pat_` (so `kici pat create` keeps working), and refuses `kici_sk_` and `kici_sa_`. A token minted from another token can never be wider than that token. The new token is capped by your own permissions **and** by the scopes of the token you called with, whether or not you pass `permissions` explicitly. Mint from an unscoped session when you need a broader token.
9464
+
9302
9465
  The full route tree is the source of truth — every method, request schema, and response schema is enumerated server-side. There is currently no auto-generated OpenAPI spec; the typed `DashboardApiType` export is the canonical contract for TypeScript clients.
9303
9466
 
9304
9467
  ### Calling the API
@@ -9317,11 +9480,16 @@ curl -sS \
9317
9480
 
9318
9481
  **Browser console (after dashboard login):**
9319
9482
 
9483
+ Mint a personal access token and pass it explicitly. Do not script against the
9484
+ dashboard's own session token. That token is short-lived and tied to your
9485
+ identity-provider session, so anything built on it stops working at the next
9486
+ renewal or sign-out.
9487
+
9320
9488
  ```js
9321
- const ns = Object.keys(localStorage).find((k) => k.startsWith('oidc.user:'));
9322
- const { access_token } = JSON.parse(localStorage.getItem(ns));
9489
+ // kici pat create --name console --expires-in-days 1 → prints the token
9490
+ const token = 'kici_pat_...';
9323
9491
  const res = await fetch('/<deployment-slug>/api/v1/orgs/<your-org-id>/runs?limit=5', {
9324
- headers: { Authorization: `Bearer ${access_token}` },
9492
+ headers: { Authorization: `Bearer ${token}` },
9325
9493
  });
9326
9494
  console.log(await res.json());
9327
9495
  ```
@@ -9346,6 +9514,12 @@ The CLI stores authentication data in `~/.kici/config` with `0600` permissions (
9346
9514
  - Routing key for webhook source identification
9347
9515
  - API key, when you logged in with `--token`
9348
9516
 
9517
+ The web dashboard holds none of these. It keeps only the tokens of your current
9518
+ sign-in, in browser storage for the tab's origin. It does not request offline
9519
+ access, so those tokens die with your identity-provider session instead of
9520
+ staying valid for weeks. Its origin also serves a Content-Security-Policy that
9521
+ restricts which scripts run and which hosts the page may contact.
9522
+
9349
9523
  ## Troubleshooting
9350
9524
 
9351
9525
  ### Browser doesn't open
@@ -9550,10 +9724,14 @@ kici init --private-registry https://npm.pkg.github.com/ \
9550
9724
  types/ # Directory for generated type declarations (kici types)
9551
9725
  package.json # Dependencies (@kici-dev/sdk)
9552
9726
  tsconfig.json # TypeScript configuration (includes types/**/*.d.ts)
9727
+ .gitignore # Keeps the generated types/ declarations untracked
9728
+ .kiciignore # Paths the workflow content hash does not cover
9553
9729
  AGENTS.md # LLM authoring context (skip with --no-agents-md)
9554
9730
  .kiciignore # Default exclusion patterns for test uploads
9555
9731
  ```
9556
9732
 
9733
+ The two `.kiciignore` files are unrelated. The one inside `.kici/` declares which paths the per-workflow content hash skips — see [Lock file and workflow drift](https://docs.kici.dev/user/lock-file-and-drift/#files-the-content-hash-skips-kicikiciignore). The one at the repo root selects which working-tree files a remote run uploads. Neither is overwritten when it already exists.
9734
+
9557
9735
  `AGENTS.md` is written by default (the interactive prompt defaults to yes, and CI / non-interactive runs write it). An existing `.kici/AGENTS.md` is never overwritten, so hand edits survive a re-run.
9558
9736
 
9559
9737
  In interactive mode (TTY), `kici init` prompts you to:
@@ -10094,6 +10272,14 @@ to see it. The bundle holds your CLI, Node and orchestrator versions, your
10094
10272
  redacted configuration, and your project's workflow and lock-file state. With
10095
10273
  `--run` it also holds the failing run's detail and logs.
10096
10274
 
10275
+ For every orchestrator the probe returned the bundle also records where that
10276
+ orchestrator's own config files live — the paths only, never the contents. An
10277
+ orchestrator that is offline is still listed, with no paths: the CLI reads them
10278
+ from the live connection, so a disconnected one has none to report.
10279
+ Each path is a host path as that orchestrator sees it. A container deployment
10280
+ names a file on the container host, which is not a file you can open from the
10281
+ machine that read the bundle.
10282
+
10097
10283
  ```bash
10098
10284
  kici report [options]
10099
10285
  ```
@@ -11245,7 +11431,7 @@ You declare a gate in your workflow with `approval`. It is available at three le
11245
11431
  - **Job** — hold the job before any of its steps run.
11246
11432
  - **Workflow** — hold the whole run before any job is dispatched.
11247
11433
 
11248
- A step-level gate can also fire **only when a check/apply step finds drift** — Terraform's plan→apply, per step. See [Drift gates](https://docs.kici.dev/user/approvals/#drift-gates-whendrift) below.
11434
+ A step-level gate can also fire **only when a check/apply step finds drift** — Terraform's plan→apply, per step. See [Drift gates](https://docs.kici.dev/user/approvals/#drift-gates-when-drift) below.
11249
11435
 
11250
11436
  Approvers are named as **teams** and **users**. A team is an operator-defined group of org members; your workflow code may name a team but can never change its membership, which is what makes a team clause a real gate rather than a suggestion. See [Approval gates (operator guide)](https://docs.kici.dev/operator/approvals/) for how operators define teams, the approval queue, and expiry; see [the architecture overview](https://docs.kici.dev/architecture/approvals/) for how a hold is evaluated and resumed.
11251
11437
 
@@ -11316,7 +11502,7 @@ approval: {
11316
11502
 
11317
11503
  | Field | Type | Description |
11318
11504
  | ----------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11319
- | `when` | `'always' \| 'drift'` | When the gate fires. `'always'` (default) gates before the element; `'drift'` gates a check/apply step only when it finds drift. See [Drift gates](https://docs.kici.dev/user/approvals/#drift-gates-whendrift). |
11505
+ | `when` | `'always' \| 'drift'` | When the gate fires. `'always'` (default) gates before the element; `'drift'` gates a check/apply step only when it finds drift. See [Drift gates](https://docs.kici.dev/user/approvals/#drift-gates-when-drift). |
11320
11506
  | `approvers` | `ApproverClause[]` | The AND list of `{ team }` / `{ user }` clauses. An empty list means "any approval-capable member". |
11321
11507
  | `reason` | `string` | A human-readable label shown in the dashboard queue and the held-for-approval status check. |
11322
11508
  | `timeout` | `number` | Per-gate expiry in **seconds**, overriding the org default. Must be a **positive integer** number of seconds; a non-positive or non-finite value is rejected at compile time. On expiry the element is rejected. |
@@ -11495,7 +11681,7 @@ Available rules:
11495
11681
  - **Branch restrictions** — only allow specific branches to deploy.
11496
11682
  - **Required reviewer approvals** — gate the run on human sign-off.
11497
11683
  - **Wait timers** — delay execution for a fixed period.
11498
- - **Concurrency limits** — prevent collisions between parallel deployments.
11684
+ - **Concurrency limits** — cap how many jobs run against the context at once.
11499
11685
 
11500
11686
  <!-- /help:contexts-protection -->
11501
11687
 
@@ -11584,7 +11770,7 @@ job('deploy', {
11584
11770
 
11585
11771
  **Skip-on-test (allow-and-warn).** On a test or local run (`kici run remote`, `kici run <event> --local`), a bound context never rejects the run. Any bound context that disallows local execution (`allowLocalExecution: false`) — or that is not configured — is **skipped**: its variables and secrets are omitted from the merge and its gates are not evaluated. The run proceeds, and a user-visible warning naming the skipped context(s) is shown both on the `kici run remote` CLI output and on the dashboard run view. This makes the test-only-variables pattern work: with `contexts: ['staging', 'my-testing']` where only `my-testing` allows local execution, a test run resolves just `my-testing`'s variables and warns that `staging` was skipped. If every bound context is skipped, the job runs with no environment variables. This is intentionally different from a fixture `secrets:` mapping, which is fail-closed — see the [testing guide](https://docs.kici.dev/user/testing-guide/).
11586
11772
 
11587
- **Unconfigured contexts contribute nothing at dispatch.** At dispatch time a bound context name with no matching configured context (and no matching glob context) simply adds no variables, secrets, or protection rules — the job still runs, exactly as a single dynamic context resolving to an as-yet-unconfigured name does today.
11773
+ **Unconfigured contexts contribute nothing at dispatch.** At dispatch time a bound context name with no matching configured context (and no matching glob context) adds no variables, secrets, or protection rules — the job still runs, exactly as a single dynamic context resolving to an as-yet-unconfigured name does today.
11588
11774
 
11589
11775
  **Registration rejects a provably-unsatisfiable binding.** When a workflow is registered, KiCI statically checks every multi-context binding: a bound context that does not exist, a disabled one, or two contexts with mutually-exclusive fixed branch / trigger-type / repository restrictions (no value can satisfy both) makes the binding provably unsatisfiable, and the registration is rejected with a precise message naming the job, the contexts, and the rule — for example `unsatisfiable context binding: job 'deploy' binds contexts [staging, my-testing] with mutually exclusive branch restrictions (no value satisfies all bound contexts)`. Bindings whose restrictions use globs are undecidable at registration and fall through to the dispatch-time gate check instead.
11590
11776
 
@@ -11787,6 +11973,36 @@ The children of a matrix job count individually against the limit. A three-child
11787
11973
  matrix bound to a context with a limit of two dispatches two children and applies
11788
11974
  the strategy above to the third.
11789
11975
 
11976
+ A job counts against the limit from the moment it is dispatched until it
11977
+ finishes. It does not have to reach an agent first.
11978
+
11979
+ #### What the limit does and does not guarantee
11980
+
11981
+ A context concurrency limit is a **throughput control**. Treat it as a cap on
11982
+ how much work runs at once, not as a lock.
11983
+
11984
+ Two events that arrive in the same instant read the limit before either job is
11985
+ recorded, so each can be admitted. The window is short — the time between one
11986
+ read and one write — but it is real, and it grows with the number of
11987
+ orchestrator processes serving the context.
11988
+
11989
+ When a job must never run beside another copy of itself — a production deploy,
11990
+ a database migration — declare a workflow-level
11991
+ [concurrency group](https://docs.kici.dev/user/concurrency/) as well:
11992
+
11993
+ ```typescript
11994
+ export default workflow('deploy', {
11995
+ on: push({ branches: ['main'] }),
11996
+ concurrency: { group: () => 'deploy-prod', max: 1 },
11997
+ jobs: [/* ... */],
11998
+ });
11999
+ ```
12000
+
12001
+ That mechanism claims its slot inside a single database transaction, so two
12002
+ runs arriving together cannot both take it. The two are complementary: the
12003
+ context limit caps throughput across every workflow bound to the context, and
12004
+ the concurrency group serializes one workflow against itself.
12005
+
11790
12006
  ## Dashboard management
11791
12007
 
11792
12008
  ### Creating contexts
@@ -12093,7 +12309,7 @@ registries: [
12093
12309
  ## Security model
12094
12310
 
12095
12311
  - **Per-context scoping.** Every `tokenSecret` and `installEnv` entry is qualified with a context name. The orchestrator runs the same protection-rule pipeline (branch / trust / concurrency / reviewer / wait-timer) against each named context **before** resolving any secret, so a workflow that wants a `production` token from a feature branch is rejected exactly like a job that tries to deploy to `production` from a feature branch. A reviewer-gated install context **pauses** the whole workflow dispatch as a workflow-scoped held run instead of resolving the token — see [Reviewer-gated installs](https://docs.kici.dev/user/private-registries/#reviewer-gated-installs) below.
12096
- - **Untrusted refs get no tokens.** When the trust resolution returns anything other than `trusted` — every fork pull request does — the orchestrator strips both `npmRegistries` and `installEnvSecrets` out of the dispatch. The install runs without auth and fails naturally on the first private dep. A fork pull request cannot observe a registry token, even if a context lacks an explicit [minimum trust](https://docs.kici.dev/user/contexts/#minimum-trust) rule.
12312
+ - **Untrusted refs get no tokens.** When the trust resolution returns anything other than `trusted` — every fork pull request does — the orchestrator strips `npmRegistries`, `installEnvSecrets`, and a container job's [registry credentials](https://docs.kici.dev/user/container-jobs/#private-images) out of the dispatch. The install runs without auth and fails naturally on the first private dep, and a private base image fails to pull. A fork pull request cannot observe a registry token, even if a context lacks an explicit [minimum trust](https://docs.kici.dev/user/contexts/#minimum-trust) rule.
12097
12313
  - **Lifecycle scripts disabled.** Whenever a private registry is in scope, the agent runs the install with `--ignore-scripts` (npm, pnpm, and yarn classic alike; yarn berry gets the equivalent `enableScripts: false`). A malicious `preinstall` / `postinstall` hook in committed `package.json` cannot read the synthesized token env vars, even though they exist in the install subprocess. For a pnpm or yarn workspace, the agent builds your in-repo dependency closure as a separate step **after** the install's auth is torn down, so build scripts never see the tokens either.
12098
12314
  - **Stderr is redacted.** If the install fails, the agent masks every token literal out of the surfaced stderr / stdout chunks before logging.
12099
12315
  - **Job-scoped env-var names.** The synthesized auth env var is `KICI_NPM_TOKEN_<jobIdShort>_<i>` where `jobIdShort` is the first 8 chars of the dispatched job id. The name is unguessable from outside the install subprocess and not reused across jobs.
@@ -12129,7 +12345,7 @@ The dashboard JSON lives at `infra/terraform/modules/grafana/dashboards/install-
12129
12345
  ## See also
12130
12346
 
12131
12347
  - [Secrets](https://docs.kici.dev/user/secrets/) — how to seed the `<context>:<secret-name>` values referenced by `tokenSecret` / `installEnv`.
12132
- - [Contexts](https://docs.kici.dev/user/contexts/) — protection rules (`branch_restrictions`, `requires_review`, `minimum_trust`) that the install gate inherits.
12348
+ - [Contexts](https://docs.kici.dev/user/contexts/) — protection rules (branch restrictions, required reviewers, minimum trust) that the install gate inherits.
12133
12349
  - [Operator: `kici-admin org-settings`](https://docs.kici.dev/operator/orchestrator/kici-admin/org-settings/#org-settings----org-level-security-policy) — the `allow_http_npm_registries` toggle and other org-scoped knobs.
12134
12350
 
12135
12351
  ---
@@ -12247,6 +12463,107 @@ and the step never holds signing credentials — the orchestrator mints and sign
12247
12463
  the token on the step's behalf from its own run records. Like `attestProvenance`,
12248
12464
  it is only available inside a running job step.
12249
12465
 
12466
+ ## ID-token claims and cloud trust policies
12467
+
12468
+ A cloud provider's OIDC trust policy decides which builds may assume a role. The
12469
+ token below is what your policy matches on, so read this section before you
12470
+ write one.
12471
+
12472
+ ### The claim set
12473
+
12474
+ | Claim | Value |
12475
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |
12476
+ | `iss` | Your orchestrator's provenance issuer |
12477
+ | `aud` | The audience you asked for |
12478
+ | `sub` | The build identity — see the two shapes below |
12479
+ | `repository` | `owner/repo` the run acted on |
12480
+ | `ref` | The branch or tag the run PRESENTS. For a pull request this is the **base** branch, not the contributor's branch |
12481
+ | `base_ref` | The same value as `ref`, named the way GitHub Actions names it |
12482
+ | `head_ref` | The pull request's HEAD branch; `''` for a non-PR run |
12483
+ | `head_repository` | `owner/repo` of the pull-request HEAD — the contributor's fork for a fork PR; `''` for a non-PR run |
12484
+ | `is_fork` | `'true'`, `'false'`, or `'unresolved'` |
12485
+ | `event_name` | The event that started the run (`push`, `pull_request:opened`, `schedule`, …) |
12486
+ | `trust_tier` | The resolved trust tier of the triggering actor, or `'unresolved'` |
12487
+ | `actor` | Provider login of the triggering actor |
12488
+ | `sha` | The run's commit |
12489
+ | `workflow_ref` | `<workflow name>@<sha>` |
12490
+ | `kici_run_id` / `kici_job_id` | The run and job this token was minted for |
12491
+ | `org_id` | Your organization id |
12492
+
12493
+ Every claim in the table is **always present**. A value the run did not resolve
12494
+ is `''` or `'unresolved'`, never omitted and never guessed. That matters: an
12495
+ absent claim makes a `StringEquals` condition pass, which would silently remove
12496
+ a constraint you wrote expecting it to be enforced.
12497
+
12498
+ ### The two `sub` shapes
12499
+
12500
+ ```
12501
+ push, tag, schedule, … repo:<owner/repo>:ref:<ref>:workflow:<workflow name>
12502
+ pull request, review repo:<owner/repo>:pull_request
12503
+ ```
12504
+
12505
+ The pull-request shape carries **no ref segment**, mirroring GitHub Actions. A
12506
+ pull request's `ref` is its base branch. So a ref-bearing subject would be
12507
+ identical for a fork pull request targeting `main` and a trusted push to `main`.
12508
+ A policy pinning that subject would hand your cloud role to any contributor who
12509
+ opened a pull request running the same workflow.
12510
+
12511
+ **A re-run keeps the shape of the run it repeats.** Re-running a pull-request
12512
+ run presents `repo:<owner/repo>:pull_request`, because it rebuilds the same
12513
+ commit from the same source. Its `event_name` claim still reads `rerun` — that
12514
+ claim says what started the run, while `sub` says which identity the run
12515
+ presents. A policy that pins the branch-shaped subject therefore does not match
12516
+ a re-run of a pull request, which is the same protection the first run gets.
12517
+
12518
+ ### A worked AWS trust policy
12519
+
12520
+ Pin `sub`, and pin the fork context too. `sub` alone tells you a pull request
12521
+ ran; it does not tell you whose code ran.
12522
+
12523
+ ```json
12524
+ {
12525
+ "Version": "2012-10-17",
12526
+ "Statement": [
12527
+ {
12528
+ "Effect": "Allow",
12529
+ "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/orch.example.com" },
12530
+ "Action": "sts:AssumeRoleWithWebIdentity",
12531
+ "Condition": {
12532
+ "StringEquals": {
12533
+ "orch.example.com:aud": "sts.amazonaws.com",
12534
+ "orch.example.com:sub": "repo:acme/app:ref:main:workflow:deploy",
12535
+ "orch.example.com:is_fork": "false",
12536
+ "orch.example.com:head_repository": "acme/app",
12537
+ "orch.example.com:trust_tier": "trusted"
12538
+ }
12539
+ }
12540
+ }
12541
+ ]
12542
+ }
12543
+ ```
12544
+
12545
+ This grants the role only to a run on `main` in `acme/app`, from code in that
12546
+ same repository, triggered by an actor your orchestrator resolved as trusted.
12547
+ A fork pull request fails on all three of the extra conditions, and a run whose
12548
+ context did not resolve fails too — `'unresolved'` matches none of them, so the
12549
+ policy fails closed.
12550
+
12551
+ To let a same-repo pull request assume the role, add a second statement pinning
12552
+ `"sub": "repo:acme/app:pull_request"` alongside `"is_fork": "false"` and
12553
+ `"head_repository": "acme/app"`.
12554
+
12555
+ ### Migrating an existing policy
12556
+
12557
+ If you already pin a ref-bearing `sub` for pull-request runs, that policy stops
12558
+ matching once you upgrade — which is the fix, because it was matching runs it
12559
+ should not have. Move it to `repo:<owner/repo>:pull_request` plus the fork
12560
+ conditions above. The same move covers a re-run of a pull request, which
12561
+ presents the pull-request subject too.
12562
+
12563
+ While you migrate, `KICI_OIDC_LEGACY_PR_SUB=1` on the orchestrator restores the
12564
+ old subject. It restores the collision with it, so treat it as a short bridge,
12565
+ not a setting. See [deprecations](https://docs.kici.dev/user/deprecations/).
12566
+
12250
12567
  ## Verifying an attestation
12251
12568
 
12252
12569
  Verify a bundle with the `kici verify-attestation` command. It establishes the
@@ -12872,6 +13189,8 @@ workflow('test', {
12872
13189
 
12873
13190
  When `max: 1` (default), runs are fully serialized within the group.
12874
13191
 
13192
+ `max` is enforced by the orchestrator's database, so the limit is cluster-wide and survives a restart. A run holding a slot keeps it across an orchestrator restart. Every orchestrator in a cluster counts against the same limit, so a group with `max: 1` runs one job at a time no matter which orchestrator dispatched it.
13193
+
12875
13194
  ## Group key examples
12876
13195
 
12877
13196
  ### Deploy per environment
@@ -12931,6 +13250,14 @@ When a workflow has both `concurrency` and `context` protection rules:
12931
13250
 
12932
13251
  This means a run that passed approval won't need re-approval if it gets queued by concurrency.
12933
13252
 
13253
+ The two caps also differ in strength. A workflow-level concurrency group claims
13254
+ its slot inside a single database transaction, so two runs that arrive together
13255
+ cannot both take it. A context
13256
+ [concurrency limit](https://docs.kici.dev/user/contexts/#concurrency-limits) is a throughput control:
13257
+ simultaneous arrivals can each be admitted before either is recorded. Declare a
13258
+ concurrency group for anything that must never run beside itself, whatever the
13259
+ context limit says.
13260
+
12934
13261
  ## Cancelling queued runs
12935
13262
 
12936
13263
  Queued runs can be cancelled before they start executing. The cancel request removes them from the queue immediately -- they don't go through the grace period since no step is running.
@@ -13141,6 +13468,17 @@ Store the secrets first with `kici-admin secret set`. Pasting a token straight
13141
13468
  into the workflow is rejected when the workflow is defined, because a token
13142
13469
  written into `.kici/` would be committed to your repository.
13143
13470
 
13471
+ The named context's protection rules run before the secret is read, exactly as
13472
+ they do for [git credentials](https://docs.kici.dev/user/patterns/git-credentials/#what-a-job-may-ask-for).
13473
+ A `prod:` reference from a branch the `prod` context restricts is refused, and
13474
+ the job is dispatched with no registry credentials — so a private image fails to
13475
+ pull rather than being pulled from a branch the context does not allow. The rule
13476
+ that refused it is named in your orchestrator's log, not in the run.
13477
+
13478
+ **An untrusted ref receives no registry credentials.** A fork pull request is
13479
+ dispatched without them, so a private base image fails to pull and a public one
13480
+ is unaffected. The run's reduced-privilege note says so.
13481
+
13144
13482
  The username is not a secret, so you may write it directly:
13145
13483
 
13146
13484
  ```typescript
@@ -13150,6 +13488,30 @@ auth: { username: 'ci-bot', tokenSecret: 'prod:REGISTRY_TOKEN' }
13150
13488
  Your orchestrator resolves these names at dispatch and sends only the resolved
13151
13489
  credentials to the agent. The agent never reads your secret store.
13152
13490
 
13491
+ ### Naming the registry
13492
+
13493
+ `auth` also takes a `registry` — the registry host the credentials belong to,
13494
+ such as `reg.internal:5000`.
13495
+
13496
+ With `image` it is optional, because KiCI reads the host off the image
13497
+ reference. With `dockerfile` it is **required**: the base image is named inside
13498
+ your Dockerfile, so there is nothing to read it from. A `dockerfile` job whose
13499
+ `auth` omits `registry` is refused when you define the workflow.
13500
+
13501
+ ```typescript
13502
+ container: {
13503
+ dockerfile: '.kici/ci.Dockerfile',
13504
+ auth: {
13505
+ registry: 'reg.internal:5000',
13506
+ usernameSecret: 'prod:REGISTRY_USER',
13507
+ tokenSecret: 'prod:REGISTRY_TOKEN',
13508
+ },
13509
+ },
13510
+ ```
13511
+
13512
+ With `dockerfile`, these credentials pull the Dockerfile's own `FROM` base — not
13513
+ a job image, since the job image is the one KiCI builds.
13514
+
13153
13515
  ### Credentials that only exist at run time
13154
13516
 
13155
13517
  A token fetched during the run — from a cloud registry's login command, for
@@ -13300,10 +13662,11 @@ The KiCI CLI reads the following environment variables to customize its behavior
13300
13662
 
13301
13663
  ## Development
13302
13664
 
13303
- | Variable | Description | Default |
13304
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
13305
- | `KICI_DEV` | Enable development mode. When `true`, uses prerelease-compatible version ranges (`>=0.0.1-0`) for dev dependencies and skips npm version resolution. | unset |
13306
- | `KICI_DEBUG` | Enable debug logging. When `true`, prints verbose diagnostics (SDK alias resolution, step-level debug logs, stack traces on errors). Equivalent to the `--debug` CLI flag. | unset |
13665
+ | Variable | Description | Default |
13666
+ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
13667
+ | `KICI_DEV` | Enable development mode. When `true`, uses prerelease-compatible version ranges (`>=0.0.1-0`) for dev dependencies and skips npm version resolution. | unset |
13668
+ | `KICI_DEV_REGISTRY` | npm registry the `@kici-dev` scope points at when `KICI_DEV` is set. `kici init` writes it into `.npmrc`; with no value it writes no `.npmrc`. | unset |
13669
+ | `KICI_DEBUG` | Enable debug logging. When `true`, prints verbose diagnostics (SDK alias resolution, step-level debug logs, stack traces on errors). Equivalent to the `--debug` CLI flag. | unset |
13307
13670
 
13308
13671
  ## Local dev plane
13309
13672
 
@@ -13637,7 +14000,7 @@ job('provision', {
13637
14000
 
13638
14001
  ### Trust tiers on internal triggers
13639
14002
 
13640
- An internally-triggered run resolves its [trust tier](https://docs.kici.dev/user/contexts/#minimum-trust) from the trigger. The tier decides the run's cache scope, whether it may run a [Dockerfile build](https://docs.kici.dev/user/container-jobs/#who-may-build), whether it receives [install secrets](https://docs.kici.dev/user/private-registries/), and whether a `minimumTrust` context holds it.
14003
+ An internally-triggered run resolves its [trust tier](https://docs.kici.dev/user/contexts/#minimum-trust) from the trigger. The tier decides the run's cache scope, whether it may run a [Dockerfile build](https://docs.kici.dev/user/container-jobs/#who-may-build), whether it receives [install secrets](https://docs.kici.dev/user/private-registries/), and whether a `minimumTrust` context holds it. It also decides whether the run's jobs receive [container-registry credentials](https://docs.kici.dev/user/container-jobs/#private-images) and their declared [git credentials](https://docs.kici.dev/user/patterns/git-credentials/#what-a-job-may-ask-for).
13641
14004
 
13642
14005
  Four rules resolve the tier, and KiCI applies them in this order:
13643
14006
 
@@ -14164,7 +14527,7 @@ The registration log line names the organization it decided against, so a refusa
14164
14527
 
14165
14528
  ### Secrets are not available
14166
14529
 
14167
- A global workflow's job is dispatched with **no secret material** — not the source repo's, and not the workflow repo's own. The organization-wide dispatch path binds no secret contexts, so a `contexts:` declaration on a global workflow resolves to nothing and any secret the steps expect is simply absent. Plan for it: a global workflow is for checks, policy and reporting that need only the two checkouts, not for deploys that need credentials.
14530
+ A global workflow's job is dispatched with **no secret material** — not the source repo's, and not the workflow repo's own. The organization-wide dispatch path binds no secret contexts, so a `contexts:` declaration on a global workflow resolves to nothing and any secret the steps expect is absent. Plan for it: a global workflow is for checks, policy and reporting that need only the two checkouts, not for deploys that need credentials.
14168
14531
 
14169
14532
  This is about your **stored secrets**, not about repository access: the job is still handed a short-lived clone token for each repo it checks out, which is how the dual checkout works at all. What it does not get is anything from a secret context.
14170
14533
 
@@ -15394,10 +15757,14 @@ kici-admin secret set <orgId> "__source__/<sourceId>" pat --value "<your-forgejo
15394
15757
  Finally, configure the forge to deliver webhooks to:
15395
15758
 
15396
15759
  ```
15397
- https://<platform-host>/webhook/<orgId>/generic/<source-name>
15760
+ https://<platform-host>/webhook/<orgId>/generic/<sourceId>
15398
15761
  ```
15399
15762
 
15400
- with the same secret you passed to `--secret`.
15763
+ with the same secret you passed to `--secret`. `<orgId>` is your Platform
15764
+ organization id — the same value you passed to `--org`, which the source's
15765
+ routing key embeds — and `<sourceId>` is the id `source add` printed. The
15766
+ orchestrator's own ingest URL has the same shape but takes the source **name**
15767
+ in place of the id.
15401
15768
 
15402
15769
  ## SSH deploy key
15403
15770
 
@@ -15591,7 +15958,7 @@ interface LocalConfig {
15591
15958
 
15592
15959
  ### SharedConfig
15593
15960
 
15594
- Shared settings stored in the PostgreSQL `config_versions` table. Defined once, shared across all instances:
15961
+ Settings stored in the PostgreSQL `config_versions` table, written and read by the `/admin/config` routes and their `kici-admin config` commands:
15595
15962
 
15596
15963
  ```typescript
15597
15964
  interface SharedConfig {
@@ -15635,11 +16002,13 @@ interface AppConfig {
15635
16002
 
15636
16003
  ### How they merge
15637
16004
 
16005
+ `resolveFullConfig()` takes a `LocalConfig` and an optional `SharedConfig` and merges them:
16006
+
15638
16007
  ```
15639
16008
  defaults (getDefaults())
15640
16009
  |
15641
16010
  v
15642
- SharedConfig (from DB) ──deepMerge──> merged layer 1+2
16011
+ SharedConfig (argument) ──deepMerge──> merged layer 1+2
15643
16012
  |
15644
16013
  v
15645
16014
  LocalConfig (from YAML) ──deepMerge──> merged layer 1+2+3
@@ -15656,29 +16025,48 @@ appConfigSchema.safeParse() ──validate──> typed AppConfig
15656
16025
 
15657
16026
  The `deepMerge` function merges objects recursively, replaces arrays (does not merge item-by-item), and skips `undefined`/`null` source values (they do not override existing values).
15658
16027
 
16028
+ **The `SharedConfig` argument is `null` in the shipped wiring.** `ConfigReloader` is the only non-test caller of `resolveFullConfig()`, and it is constructed with `sharedStore: null`. So the DB layer is skipped and the effective chain is defaults → YAML → env. The `config_versions` table is read by the `/admin/config` write and inspection routes, by `kici-admin rotate-key`, and by the cluster join flow — never by a running orchestrator's own config.
16029
+
15659
16030
  ## Resolution chain
15660
16031
 
15661
- ### Two-phase design
16032
+ ### Startup
16033
+
16034
+ `server.ts` and `standalone.ts` both call `loadConfig()`, which parses `KICI_*` environment variables against the flat schema in `config.ts`. No YAML file and no database row participates:
15662
16035
 
15663
16036
  ```
15664
- Phase 1 (local-only):
15665
- YAML file + KICI_ env vars
15666
- |
15667
- v
15668
- resolveLocalConfig() -> { databaseUrl, instanceId, port, mode }
15669
- |
15670
- v
15671
- Connect to PostgreSQL
15672
- |
15673
- v
15674
- Phase 2 (full merge):
15675
- defaults -> DB -> YAML -> env
15676
- |
15677
- v
15678
- resolveFullConfig() -> AppConfig
16037
+ Process start
16038
+ |
16039
+ v
16040
+ loadConfig() -> envDef.parse(process.env) -> AppConfig
16041
+ |
16042
+ v
16043
+ Connect to PostgreSQL, run migrations
16044
+ |
16045
+ v
16046
+ Start server (HTTP, WS, scaler, cluster)
15679
16047
  ```
15680
16048
 
15681
- **Why two phases?** The database URL must come from local config (YAML or env var) because we need it to connect to PostgreSQL. But the shared config is stored in PostgreSQL. This circular dependency is broken by resolving local config first (Phase 1), connecting to the DB, then doing the full merge (Phase 2).
16049
+ The database URL therefore has to be an environment variable: the orchestrator needs it to reach PostgreSQL, and the shared config lives in PostgreSQL.
16050
+
16051
+ ### Reload
16052
+
16053
+ `resolveLocalConfig()` and `resolveFullConfig()` run on the reload path, not at startup:
16054
+
16055
+ ```
16056
+ SIGHUP / POST /admin/config/reload / kici-admin config reload
16057
+ |
16058
+ v
16059
+ resolveLocalConfig() -> YAML file + KICI_ env overlay
16060
+ |
16061
+ v
16062
+ resolveFullConfig(local, null) -> defaults -> YAML -> env -> AppConfig
16063
+ |
16064
+ v
16065
+ Hold databaseUrl, port, instanceId and storage at their startup values
16066
+ |
16067
+ v
16068
+ Atomic swap into ConfigReloader.currentConfig
16069
+ ```
15682
16070
 
15683
16071
  ### Env var processing
15684
16072
 
@@ -15690,43 +16078,6 @@ Environment variables are processed in two stages:
15690
16078
 
15691
16079
  Type coercion is applied based on known field types: numeric fields are parsed as numbers, boolean fields are compared against `"true"`, all others remain strings.
15692
16080
 
15693
- ## Two-phase bootstrap
15694
-
15695
- ```
15696
- ┌─────────────────┐
15697
- │ Process Start │
15698
- └────────┬────────┘
15699
-
15700
-
15701
- ┌─────────────────┐
15702
- │ Load YAML + │ resolveLocalConfig()
15703
- │ Env Overrides │ -> databaseUrl, instanceId, port, mode
15704
- └────────┬────────┘
15705
-
15706
-
15707
- ┌─────────────────┐
15708
- │ Connect to │ PostgreSQL
15709
- │ Database │ Run migrations
15710
- └────────┬────────┘
15711
-
15712
-
15713
- ┌─────────────────┐
15714
- │ Load Shared │ SharedConfigStore.getLatest()
15715
- │ Config from DB │ -> decrypt -> SharedConfig
15716
- └────────┬────────┘
15717
-
15718
-
15719
- ┌─────────────────┐
15720
- │ Full Merge │ resolveFullConfig(local, db, env)
15721
- │ + Validate │ -> AppConfig
15722
- └────────┬────────┘
15723
-
15724
-
15725
- ┌─────────────────┐
15726
- │ Start Server │ HTTP, WS, scaler, cluster
15727
- └─────────────────┘
15728
- ```
15729
-
15730
16081
  ## DB schema
15731
16082
 
15732
16083
  ### config_versions table
@@ -15826,7 +16177,7 @@ flowchart TD
15826
16177
  trigger --> execute["executeReload()<br/>boolean mutex"]
15827
16178
 
15828
16179
  execute --> resolveLocal["resolveLocalConfig()"]
15829
- execute --> getLatest["getLatest (DB)"]
16180
+ execute --> getLatest["getLatest (DB)<br/>skipped: sharedStore is null"]
15830
16181
  execute --> resolveFull["resolveFullConfig()<br/>(merge + validate)"]
15831
16182
 
15832
16183
  resolveLocal --> check["Check restart-required fields"]
@@ -15845,26 +16196,26 @@ flowchart TD
15845
16196
  - **Mutex:** Boolean flag prevents concurrent reloads. Second reload returns `{ success: false, errors: ["Reload already in progress"] }`.
15846
16197
  - **Debounce:** Rapid triggers (e.g., multiple SIGHUP signals) are collapsed into a single reload with a 500ms window.
15847
16198
  - **Validation before swap:** The new config must pass full schema validation. On failure, the old config is preserved and an error is logged.
15848
- - **Restart-required detection:** Fields like `databaseUrl`, `port`, `instanceId` are compared. If changed, the old values are preserved in the applied config and a warning is logged.
16199
+ - **Restart-required detection:** `databaseUrl`, `port`, `instanceId` and `storage` are compared. If changed, the old values are preserved in the applied config and a warning is logged.
15849
16200
  - **No crash on failure:** The orchestrator always keeps running with the old config if anything goes wrong during reload.
15850
16201
 
15851
16202
  ### Subsystem callbacks
15852
16203
 
15853
16204
  The `ConfigReloader` uses a dependency injection pattern with callbacks for subsystem re-initialization:
15854
16205
 
15855
- | Callback | When Called | Purpose |
15856
- | --------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- |
15857
- | `onProviderChange` | Provider config changed | Reserved callback (providers are now DB-managed via sources table; currently always a no-op) |
15858
- | `onScalerReload` | Always on successful reload | Reload scaler YAML config |
15859
- | `onPlatformReconnect` | Platform URL or token changed | Reconnect WS to Platform relay |
15860
- | `onConfigApplied` | Always on successful reload | Atomic config reference swap, increment local config version |
16206
+ | Callback | When Called | Purpose |
16207
+ | --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
16208
+ | `onProviderChange` | Provider config changed | Reserved callback. Providers are DB-managed via the sources table, so the change detector always reports no change |
16209
+ | `onScalerReload` | Always on successful reload | Reload scaler YAML config, from the path the process started with |
16210
+ | `onPlatformReconnect` | Platform URL or token changed | Logs that the Platform connection settings changed. The connection is not re-established; `standalone.ts` registers no handler |
16211
+ | `onConfigApplied` | Always on successful reload | Atomic config reference swap, increment local config version |
15861
16212
 
15862
16213
  ### Prometheus metrics
15863
16214
 
15864
- | Metric | Type | Labels | Description |
15865
- | ------------------------------- | ------- | ----------------------------------------------------------------------- | ------------------------------------- |
15866
- | `kici_orch_config_reload_total` | Counter | `result` (attempted/success/failed), `source` (sighup/http/cluster/cli) | Config reload attempts and outcomes |
15867
- | `kici_orch_config_version` | Gauge | -- | Current shared config version from DB |
16215
+ | Metric | Type | Labels | Description |
16216
+ | ------------------------------- | ------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
16217
+ | `kici_orch_config_reload_total` | Counter | `result` (attempted/success/failed), `source` (sighup/http/cluster/cli) | Config reload attempts and outcomes |
16218
+ | `kici_orch_config_version` | Gauge | -- | Shared config version from the DB. Set only when the reload path reads a version, so it carries no value today |
15868
16219
 
15869
16220
  ## Multi-Provider
15870
16221
 
@@ -15896,40 +16247,42 @@ Each source record contains its own `appId` and `privateKey` (stored as scoped s
15896
16247
 
15897
16248
  ### Heartbeat config version
15898
16249
 
15899
- In clustered deployments, each orchestrator includes its current config version in Raft heartbeat metadata via the `configVersion` optional field on the `peerHeartbeatSchema`.
16250
+ In clustered deployments, each orchestrator includes its config version in Raft heartbeat metadata via the `configVersion` optional field on the `peerHeartbeatSchema`.
16251
+
16252
+ **That number is a local reload counter, not a shared config version.** `onConfigApplied` increments it on every successful reload and publishes the new value to the peer registry. It counts how many times this instance has reloaded.
15900
16253
 
15901
16254
  When the `PeerRegistry` processes a heartbeat:
15902
16255
 
15903
16256
  1. Compare `localConfigVersion` with `peer.configVersion`
15904
16257
  2. If `peer.configVersion > localConfigVersion` AND both are > 0:
15905
16258
  - Invoke the `onConfigVersionBehind` callback
15906
- - This triggers a config reload from the database
16259
+ - This triggers a config reload, which re-reads the environment and the local YAML file
15907
16260
 
15908
16261
  ### Auto-remediation flow
15909
16262
 
15910
16263
  ```
15911
- Orchestrator A (version 5) Orchestrator B (version 3)
16264
+ Orchestrator A (reloaded 5x) Orchestrator B (reloaded 3x)
15912
16265
  │ │
15913
16266
  │──── heartbeat(configVersion=5) ────>│
15914
16267
  │ │
15915
16268
  │ compare: 5 > 3
15916
- │ trigger reload from DB
16269
+ │ trigger reload
15917
16270
  │ │
15918
- │ resolveFullConfig()
15919
- │ -> version 5
16271
+ │ resolveFullConfig(local, null)
16272
+ │ -> counter becomes 4
15920
16273
  │ │
15921
- │<── heartbeat(configVersion=5) ──────│
16274
+ │<── heartbeat(configVersion=4) ──────│
15922
16275
  │ │
15923
- │ both at version 5 ✓ │
15924
16276
  ```
15925
16277
 
16278
+ B converges on A's count only after it has reloaded as many times as A has. Each instance reads its own environment and its own YAML file, so the two agree on content only when those inputs agree.
16279
+
15926
16280
  ### Guard conditions
15927
16281
 
15928
16282
  - Version comparison only triggers when **both** local and peer versions are > 0
15929
16283
  - This prevents false triggers from:
15930
- - Legacy orchestrators that do not report `configVersion` (field is optional, defaults to 0)
15931
- - Newly started orchestrators before their first config load
15932
- - The `localConfigVersion` is a monotonically incrementing local counter (incremented on each successful reload)
16284
+ - Orchestrators that do not report `configVersion` (field is optional, defaults to 0)
16285
+ - Newly started orchestrators before their first reload
15933
16286
 
15934
16287
  ## See also
15935
16288
 
@@ -15960,7 +16313,7 @@ GitHub --> Platform Relay --> Orchestrator --> Agent
15960
16313
 
15961
16314
  1. **Provider sends webhook** to the Platform relay endpoint.
15962
16315
  2. **Platform routes the webhook** to the right orchestrator over WebSocket and forwards the body bytes verbatim. Platform never sees customer HMAC secrets — signature verification happens entirely on the orchestrator after reassembly.
15963
- 3. **Orchestrator verifies signature** (HMAC-SHA256 against per-source webhook secret, with dual-secret rotation support).
16316
+ 3. **Orchestrator admits the delivery**, then **verifies the signature** (HMAC-SHA256 against per-source webhook secret, with dual-secret rotation support). Admission runs first, on the routing key alone: when the ingest admission controller sheds, the orchestrator records an `event_log` breadcrumb with status `shed` and ACKs `shed_retry_later`, which the Platform answers as **429** with `Retry-After`. See [ingest admission shed](https://docs.kici.dev/architecture/webhooks/webhook-delivery/#ingest-admission-shed-step-3).
15964
16317
  4. **Orchestrator dedup check** against dual-layer `DedupCache` (in-memory set + `dedup_cache` DB table).
15965
16318
  5. **Orchestrator resolves provider** by looking up the provider bundle from the `ProviderRegistry` using `getByRoutingKey()` (exact match first, falls back to provider type prefix for backward compatibility). Skips processing if the provider is unknown.
15966
16319
  6. **Orchestrator normalizes** the webhook via the provider's `WebhookNormalizer` (extracts branch, event type, action, sender).
@@ -16087,7 +16440,7 @@ Build Job Dispatch --> Build Agent (kici:role:builder + matching kici:os:/kici:a
16087
16440
  | |-- npm ci in .kici/
16088
16441
  | |-- Pack .kici/ source (portable tar.gz, excludes node_modules)
16089
16442
  | |-- Pack .kici/node_modules (portable tar.gz)
16090
- | |-- Upload source tarball to cache (source/{contentHash}.tar.gz)
16443
+ | |-- Upload source tarball to cache (source/v2/{orgId}/{sourceTarDigest}.tar.gz)
16091
16444
  | |-- Upload deps tarball to cache (deps/{plat}-{arch}/{depsHash}.tar.gz)
16092
16445
  | |-- Upload deps companion .hash file
16093
16446
  | |-- Report success (cache.upload.complete × 2)
@@ -16162,7 +16515,7 @@ Dep cache misses alone do **not** trigger a build job. Deps are platform-specifi
16162
16515
 
16163
16516
  ### Cross-source / no-contentHash workflows
16164
16517
 
16165
- - **Lock files without `contentHash`** (schema v1) skip the source cache entirely; agents compile from source. Regenerate lock files with `kici compile` to enable caching. The current lock file schema version is 39.
16518
+ - **Lock files without `contentHash`** (schema v1) skip the source cache entirely; agents compile from source. Regenerate lock files with `kici compile` to enable caching. The current lock file schema version is 41.
16166
16519
  - **Cross-source / global-workflow dispatch** (a workflow registered against source A fired by a webhook on source B) bypasses both caches. The registration's lock file entry still carries `contentHash`, but the cross-source path always clone-and-installs — the eval temp dir doesn't ship `@kici-dev/sdk`. The execution agent still verifies `contentHash` against the cloned source for drift detection.
16167
16520
 
16168
16521
  ### Build deduplication
@@ -16201,7 +16554,7 @@ Both source and dep caches use `S3CacheStorage` as the sole backend. The `CacheS
16201
16554
 
16202
16555
  Cache keys reflect that source tarballs and deps have different platform characteristics:
16203
16556
 
16204
- - **Source:** `source/{contentHash}.tar.gz` — platform-agnostic. Raw TypeScript source is identical regardless of CPU architecture, so one entry is shared across all platforms. `contentHash` is the per-workflow hash from the lock file (`SHA-256(COMPILE_SCHEMA_VERSION + ":" + rawSource [+ "\0" + assetDigest])`, where `COMPILE_SCHEMA_VERSION = 5` and line endings are normalized to LF so the hash agrees across platforms).
16557
+ - **Source:** `source/v2/{orgId}/{sourceTarDigest}.tar.gz`, with a `source/v2/{orgId}/{contentHash}.hash` pointer — platform-agnostic, and scoped to the owning organization so two repositories with matching `.kici/` trees never share one object. Raw TypeScript source is identical regardless of CPU architecture, so one entry is shared across all platforms. `contentHash` is the per-workflow hash from the lock file (`SHA-256(COMPILE_SCHEMA_VERSION + ":" + rawSource [+ "\0" + assetDigest])`, where `COMPILE_SCHEMA_VERSION = 7` and line endings are normalized to LF so the hash agrees across platforms).
16205
16558
  - **Deps:** `deps/{platform}-{arch}/{depsHash}.tar.gz`, with a
16206
16559
  `deps/{platform}-{arch}/{lockfileHash}.hash` pointer holding that hash — the
16207
16560
  tarball is addressed by its own content, so two builds sharing a lock file
@@ -16932,14 +17285,14 @@ Shared business logic used by all three tiers. Single source of truth for cross-
16932
17285
  - Label utilities (platform label derivation, runsOn normalization, `kici:*` set-only reserved namespace, role labels)
16933
17286
  - Host inventory (the canonical queryable host-roster schema shared by the orchestrator's roster store, the agent-facing inventory API, and the SDK's `ctx.kici.inventory`)
16934
17287
  - Audit policy and retention (per-action access-log sampling, warm-retention windows for cold-store eligibility, federated activity row schema)
16935
- - Scaler backend type enum (`container`, `bare-metal`, `firecracker`, `kubernetes`, `event`) and the reserved `kici.` event-name prefix that keeps a user step from forging a system event
17288
+ - Scaler backend type enum (`container`, `bare-metal`, `firecracker`, `kubernetes`, `event`; the orchestrator config rejects `kubernetes`) and the reserved `kici.` event-name prefix that keeps a user step from forging a system event
16936
17289
  - Job resource vocabulary (the requests/limits shape the SDK accepts, the compiler validates and emits, the orchestrator uses for capacity math and kernel-side enforcement, and the dashboard displays)
16937
17290
  - Registration trigger type enum (registerable trigger discriminator)
16938
17291
  - Sandbox capability set (the Linux capability names a container sandbox may add or drop, shared by the SDK validator, the compiler, and the dispatch resolver)
16939
17292
  - Plan tier vocabulary (the hosted plan tiers and the purchasable subset, shared by the Platform and the browser dashboard)
16940
17293
  - Infrastructure alert vocabulary (the diagnostics alert types and severities the Platform mints and the dashboard and `kici` CLI render)
16941
17294
  - Metric catalog (the generated Prometheus metric inventory, its naming policy, and metric-kind compatibility checks)
16942
- - Bundler config (shared bundler configuration consumed by `e2e/helpers/service-deploy.ts`; the agent runtime uses the `@kici-dev/core/ts-loader-hook` to transform TypeScript on import, with no runtime bundler step)
17295
+ - Bundler config (the shared workflow-bundle configuration factory on the barrel; the agent runtime uses the `@kici-dev/core/ts-loader-hook` to transform TypeScript on import, so no runtime path bundles a workflow)
16943
17296
 
16944
17297
  > Source: `packages/engine/src/`
16945
17298
 
@@ -16959,7 +17312,7 @@ It also runs the **local dev plane** -- an on-demand, fully local execution stac
16959
17312
 
16960
17313
  ### `@kici-dev/core`
16961
17314
 
16962
- Light shared utilities with no server-side dependencies. It provides JSON-structured logging, error helpers, async-local-storage request context, and human-readable formatting (`formatBytes`/`formatDuration`/`formatUptime`). It also provides cryptographic helpers (`sha256`/`sha256File`/`deriveSharedSecret` plus symmetric encrypt/decrypt), retry-backoff computation, and the shared diagnostics-result contract. The rest of its surface ships as subpath entry points: the temp-directory allocator and its garbage collector, package-manager detection, CI-environment detection, and the idempotent-step runner (the check / confirm / apply primitive behind idempotent steps). Finally it supplies zx initialization (`initZx()`) and the TypeScript loader hook that transforms TypeScript on import. It is the dependency-light core that the SDK, compiler, and `kici` CLI consume directly so they stay free of heavier server-only dependencies. `@kici-dev/shared` re-exports it, so existing `@kici-dev/shared` import paths keep working.
17315
+ Light shared utilities with no server-side dependencies. It provides JSON-structured logging, error helpers, async-local-storage request context, and human-readable formatting (`formatBytes`/`formatDuration`/`formatUptime`). It also provides cryptographic helpers (`sha256`/`sha256File`/`deriveSharedSecret` plus symmetric encrypt/decrypt), retry-backoff computation, and the shared diagnostics-result contract. The rest of its surface ships as subpath entry points: the temp-directory allocator and its garbage collector, package-manager detection, CI-environment detection, and the idempotent-step runner (the check / confirm / apply primitive behind idempotent steps). One further subpath holds the `.kici/` source digest: the single content-hash definition the compiler writes into the lock file and the agent recomputes as its drift gate. Finally it supplies zx initialization (`initZx()`) and the TypeScript loader hook that transforms TypeScript on import. It is the dependency-light core that the SDK, compiler, and `kici` CLI consume directly so they stay free of heavier server-only dependencies. `@kici-dev/shared` re-exports it, so existing `@kici-dev/shared` import paths keep working.
16963
17316
 
16964
17317
  > Source: `packages/core/src/`
16965
17318