@kici-dev/compiler 0.6.1 → 0.8.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 (57) hide show
  1. package/dist/cli.js +2 -2
  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/local.d.ts +9 -2
  7. package/dist/commands/local.js +14 -4
  8. package/dist/commands/preview.js +1 -1
  9. package/dist/commands/report/identity.d.ts +11 -0
  10. package/dist/commands/report/identity.js +7 -2
  11. package/dist/commands/run-routed.js +4 -0
  12. package/dist/commands/run.js +5 -2
  13. package/dist/commands/runs/logs.js +3 -2
  14. package/dist/commands/types.d.ts +6 -1
  15. package/dist/commands/types.js +2 -1
  16. package/dist/execution/executor.js +7 -1
  17. package/dist/llm-context/llms-architecture.txt +73 -87
  18. package/dist/llm-context/llms-cli-remote.txt +53 -8
  19. package/dist/llm-context/llms-cli.txt +71 -36
  20. package/dist/llm-context/llms-features-execution.txt +52 -6
  21. package/dist/llm-context/llms-features.txt +137 -6
  22. package/dist/llm-context/llms-full.txt +558 -180
  23. package/dist/llm-context/llms-getting-started.txt +5 -5
  24. package/dist/llm-context/llms-patterns.txt +81 -5
  25. package/dist/llm-context/llms-providers.txt +6 -2
  26. package/dist/llm-context/llms-sdk-runtime.txt +33 -18
  27. package/dist/llm-context/llms-sdk.txt +47 -7
  28. package/dist/llm-context/llms.txt +8 -8
  29. package/dist/local-plane/orchestrator-process.d.ts +0 -8
  30. package/dist/local-plane/orchestrator-process.js +6 -14
  31. package/dist/local-plane/paths.d.ts +1 -0
  32. package/dist/local-plane/paths.js +1 -0
  33. package/dist/local-plane/plane-log.d.ts +27 -0
  34. package/dist/local-plane/plane-log.js +39 -0
  35. package/dist/local-plane/plane-manager.js +2 -2
  36. package/dist/local-plane/plane-trigger.d.ts +28 -0
  37. package/dist/local-plane/plane-trigger.js +57 -2
  38. package/dist/local-plane/postgres.js +9 -6
  39. package/dist/local-plane/run-follow.js +2 -1
  40. package/dist/lockfile/generator.js +25 -9
  41. package/dist/lockfile/hasher.d.ts +5 -13
  42. package/dist/lockfile/hasher.js +1 -15
  43. package/dist/lockfile/workspace-siblings.d.ts +46 -0
  44. package/dist/lockfile/workspace-siblings.js +197 -0
  45. package/dist/remote/output/streaming.d.ts +12 -0
  46. package/dist/remote/output/streaming.js +20 -1
  47. package/dist/remote/platform-client.d.ts +2 -0
  48. package/dist/templates/package-json.d.ts +9 -7
  49. package/dist/templates/package-json.js +11 -9
  50. package/dist/test-runner/job-executor.js +1 -1
  51. package/dist/test-runner/rule-evaluator.js +1 -1
  52. package/dist/types.d.ts +6 -1
  53. package/package.json +7 -9
  54. package/sbom.spdx.json +123 -123
  55. package/dist/postinstall.d.ts +0 -9
  56. package/dist/postinstall.js +0 -62
  57. 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)
@@ -590,7 +590,7 @@ Point to a local SDK checkout for IDE autocompletion:
590
590
  {
591
591
  "name": "my-project-kici",
592
592
  "devDependencies": {
593
- "@kici-dev/sdk": ">=0.0.1-0"
593
+ "@kici-dev/sdk": "latest"
594
594
  },
595
595
  "kici": {
596
596
  "sdkPath": "../../packages/sdk"
@@ -602,7 +602,7 @@ The `sdkPath` field tells the compiler where to resolve TypeScript path mappings
602
602
 
603
603
  ### KICI_DEV environment variable
604
604
 
605
- Set `KICI_DEV=true` to use a prerelease-compatible version range (`>=0.0.1-0`) in generated files, which resolves prerelease builds from a local Verdaccio registry:
605
+ Set `KICI_DEV=true` to pin the SDK to the `latest` dist-tag in generated files, which resolves prerelease builds from a local Verdaccio registry:
606
606
 
607
607
  ```bash
608
608
  KICI_DEV=true npx kici init
@@ -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
 
@@ -6386,6 +6502,17 @@ interface StepContext<TInputs = Record<string, unknown>> {
6386
6502
  provider?: string;
6387
6503
  /** Whether this execution was triggered by `kici run remote` (developer-initiated remote run) */
6388
6504
  isTestRun: boolean;
6505
+ /** The job's own checked-out repository (present for every job that checks out); `withWrite` opens a write window for it — see [Git credentials](https://docs.kici.dev/user/patterns/git-credentials/) */
6506
+ repo?: {
6507
+ identifier: string;
6508
+ path: string;
6509
+ ref?: string;
6510
+ sha?: string;
6511
+ withWrite(
6512
+ opts: { permissions?: Record<string, string>; credential?: string },
6513
+ fn: () => Promise<void>,
6514
+ ): Promise<void>;
6515
+ };
6389
6516
  /** Registering repo of a global workflow (undefined for non-global workflows) */
6390
6517
  workflowRepo?: RepoInfo;
6391
6518
  /** Repo where the triggering event occurred (undefined for non-global workflows) */
@@ -6586,6 +6713,8 @@ const deploy = job('deploy', {
6586
6713
 
6587
6714
  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
6715
 
6716
+ 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.
6717
+
6589
6718
  ```typescript
6590
6719
  const publish = job('publish', {
6591
6720
  steps: [
@@ -6625,6 +6754,8 @@ interface HostInventoryEntry {
6625
6754
 
6626
6755
  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
6756
 
6757
+ `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.
6758
+
6628
6759
  ```typescript
6629
6760
  // All hosts:
6630
6761
  const all = await ctx.kici.inventory.query();
@@ -7320,7 +7451,7 @@ triggered, or the repo had no lock file at that commit.
7320
7451
  source-registration mismatch).
7321
7452
  2. **Did anything match?** Run `kici preview push --branch <your-branch>`
7322
7453
  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.
7454
+ cover that event/branch — the push was delivered and matched nothing.
7324
7455
  3. **Was there a lock file?** A repository with **no** `kici.lock.json` at the
7325
7456
  pushed commit produces no run and is not an error. Confirm the lock file is
7326
7457
  committed and current (see [Lock-file drift](https://docs.kici.dev/user/common-failures/#lock-file-drift)).
@@ -7639,7 +7770,7 @@ KiCI uses a **two-artifact model**: TypeScript workflows are the source of truth
7639
7770
  ## Why the lock file matters
7640
7771
 
7641
7772
  - **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.
7773
+ - **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
7774
 
7644
7775
  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
7776
 
@@ -7647,37 +7778,38 @@ If you change a workflow file (`.ts`) but do **not** regenerate and commit the l
7647
7778
 
7648
7779
  The lock file (`kici.lock.json`) is a JSON file with the following top-level fields:
7649
7780
 
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. |
7781
+ | Field | Description |
7782
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
7783
+ | `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. |
7784
+ | `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). |
7785
+ | `source` | Reference to the source file and export (e.g., `{ file: '.kici/workflows/ci.ts', export: '#default' }`). |
7786
+ | `contentHash` | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. |
7787
+ | `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. |
7788
+ | `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. |
7789
+ | `workflows` | Array of workflow entries, each with its own `contentHash`, `compileSchemaVersion`, triggers, and jobs. |
7658
7790
 
7659
7791
  Each workflow entry includes:
7660
7792
 
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`. |
7793
+ | Field | Description |
7794
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7795
+ | `name` | Workflow name. |
7796
+ | `source` | Per-workflow source file and export reference. |
7797
+ | `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. |
7798
+ | `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. |
7799
+ | `triggers` | Trigger definitions extracted from the workflow (used by the orchestrator for event matching). |
7800
+ | `jobs` | Job definitions with scheduling metadata (runsOn, needs, matrix, contexts, concurrency, container, checkout, gracePeriod, label routing, dynamic fields, etc.). |
7801
+ | `rules` | Workflow-level conditional rules (optional). Stored as dynamic references since rule functions cannot be serialized. |
7802
+ | `description` | Optional workflow description. |
7803
+ | `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). |
7804
+ | `resolvedHashFiles` | Resolved file paths from `hashFiles` at compile time (optional). Recorded so the agent can verify without re-discovering. |
7805
+ | `contexts` | Secret contexts declared by the workflow (optional). The orchestrator validates access to each context before dispatch. |
7806
+ | `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/). |
7807
+ | `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/). |
7808
+ | `concurrency` | Workflow-level concurrency config: `hasGroup`, `cancelInProgress`, `max` (optional). See [concurrency groups](https://docs.kici.dev/user/concurrency/). |
7809
+ | `timeout` | Whole-run wall-clock timeout in milliseconds (optional). The orchestrator reads this at run creation to set the run deadline. |
7810
+ | `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/). |
7811
+ | `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). |
7812
+ | Hook flags | Boolean flags (`hasOnCancel`, `hasCleanup`, `hasOnSuccess`, `hasOnFailure`) indicating which lifecycle hooks are defined. Job entries additionally have `hasBeforeStep` and `hasAfterStep`. |
7681
7813
 
7682
7814
  Step entries carry their own capability flags, so the orchestrator can reason about a step without loading your TypeScript:
7683
7815
 
@@ -7758,9 +7890,40 @@ kici compile --check
7758
7890
 
7759
7891
  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
7892
 
7893
+ ## Files the content hash skips (`.kici/.kiciignore`)
7894
+
7895
+ 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:
7896
+
7897
+ ```
7898
+ node_modules/
7899
+ types/
7900
+ .npmrc
7901
+ package-lock.json
7902
+ pnpm-lock.yaml
7903
+ kici.lock.json
7904
+ ```
7905
+
7906
+ 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.
7907
+
7908
+ `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.
7909
+
7910
+ 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/`.
7911
+
7912
+ 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.
7913
+
7914
+ 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`.
7915
+
7916
+ :::caution[The file replaces the defaults — it does not add to them]
7917
+ 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.
7918
+ :::
7919
+
7920
+ `.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.
7921
+
7922
+ > **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.
7923
+
7761
7924
  ## Extra files in the content hash (`hashFiles`)
7762
7925
 
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.
7926
+ 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
7927
 
7765
7928
  Use the `hashFiles` option on a workflow to include additional paths or glob patterns (relative to the repo root) in the content hash:
7766
7929
 
@@ -7771,14 +7934,14 @@ export default workflow('deploy', {
7771
7934
  });
7772
7935
  ```
7773
7936
 
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.
7937
+ 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
7938
 
7776
7939
  ## Agent-side safety net
7777
7940
 
7778
7941
  If drift still occurs (e.g. someone committed only the `.ts` change), the agent detects it at run time before any step runs:
7779
7942
 
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.
7943
+ - 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.
7944
+ - 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
7945
 
7783
7946
  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
7947
 
@@ -7790,6 +7953,7 @@ So even without a pre-commit or CI check, a stale lock file will cause the run t
7790
7953
  | Catch drift before commit | Install a pre-commit hook with `kici hook install`. |
7791
7954
  | Catch broken source in CI | Run `kici compile --check` in CI. |
7792
7955
  | Bust cache on external files | Add `hashFiles: ['config.json']` to include non-workflow files in the content hash. |
7956
+ | Skip a path inside `.kici/` | List it in `.kici/.kiciignore` — remember the file replaces the defaults. |
7793
7957
  | Fail fast when drift remains | Rely on the agent’s hash verification when it compiles from source. |
7794
7958
 
7795
7959
  ## See also
@@ -8420,7 +8584,7 @@ Manage the **local dev plane** — the warm, per-user orchestrator (plus its own
8420
8584
  kici local up [--offline | --connected] # Start, or reuse an already-running plane
8421
8585
  kici local status [--json] # Port, pid, PostgreSQL backend, attachment mode, readiness
8422
8586
  kici local down # Stop the orchestrator and its PostgreSQL, verifying the port is freed
8423
- kici local logs # Print the orchestrator log file path
8587
+ kici local logs # Print the plane log paths and rotation policy
8424
8588
  kici local attach # Attach to the Platform (hybrid mode)
8425
8589
  kici local detach # Return the plane to offline (independent) mode
8426
8590
  kici local trust-root <file> # Export the dev-signed trust root for offline verification
@@ -8437,6 +8601,8 @@ The plane runs in one of two modes:
8437
8601
 
8438
8602
  `kici local status` reports a plane whose process is alive but whose readiness probe fails — for example when its PostgreSQL has stopped — as running but not ready, together with its readiness checks, rather than as not running. When the holder is a KiCI plane orchestrator that this config directory did not start — a plane belonging to another `KICI_CONFIG_DIR`, or one whose record here was lost — status names it as such rather than as not ready, since its readiness is never probed, and points at `kici local down`, which does reclaim it. When the port is held by a process that is not a KiCI plane orchestrator, `kici local status` names that holder instead and points at `KICI_LOCAL_ORCH_PORT`, because `kici local down` will not stop it.
8439
8603
 
8604
+ The plane writes its orchestrator log to `orchestrator.log` in its state directory and, when it runs embedded PostgreSQL, the PostgreSQL log to `orchestrator.log.pg` beside it. `kici local logs` prints their location. Each is rotated to a `.1` sibling when it reaches 50 MB, at the next plane start — the plane keeps the current log and one previous generation, so neither can grow without bound.
8605
+
8440
8606
  Pass `--json` for machine-readable output. It prints one object and exits 0 for
8441
8607
  every state, including when the plane is stopped — the state is in the payload,
8442
8608
  not the exit code:
@@ -8729,7 +8895,7 @@ Synopsis: `kici local down`
8729
8895
 
8730
8896
  ### `kici local logs`
8731
8897
 
8732
- Print the local dev plane orchestrator log path
8898
+ Print the local dev plane log paths and rotation policy
8733
8899
 
8734
8900
  Synopsis: `kici local logs`
8735
8901
 
@@ -8959,6 +9125,12 @@ If you belong to a single organization, the org is resolved automatically. If
8959
9125
  you belong to several, pass an `orgId` argument to any tool (use `list_orgs` to
8960
9126
  find it).
8961
9127
 
9128
+ Only an **active** membership counts: an organization you have been suspended
9129
+ in, one that has been disabled, and one that has been deleted are all skipped.
9130
+ So a single active membership alongside a disabled one still resolves
9131
+ automatically, and a tool call against an organization you are suspended in is
9132
+ refused with the same message the dashboard gives.
9133
+
8962
9134
  ### Limits and pagination
8963
9135
 
8964
9136
  The MCP server applies a few bounds so an agent loop can't overwhelm the shared
@@ -9048,7 +9220,7 @@ you hold, so an agent cannot escalate beyond its creator.
9048
9220
 
9049
9221
  **Repository scope comes along too.** If your role is restricted to a set of
9050
9222
  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`,
9223
+ outside it are not there: they are filtered out of `list_runs`,
9052
9224
  `cancel_runs_by_branch` skips them, and naming one directly answers "not found"
9053
9225
  — the same answer a run id that does not exist gets, so an agent cannot use the
9054
9226
  tools to discover which repositories it is missing. Your organization's audit
@@ -9291,14 +9463,18 @@ Tokens authenticate; RBAC authorizes. Every org-scoped route runs `orgContextMid
9291
9463
 
9292
9464
  ### Configurable surfaces
9293
9465
 
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:
9466
+ 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
9467
 
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}`
9468
+ - **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}`†
9469
+ - **Org & membership:** `/orgs` (POST)†, `/orgs/:customerId`, `/orgs/:customerId/{members,roles,api-keys,orchestrator-keys,service-accounts,billing,trust-policies}`
9298
9470
  - **Workflows & runs:** `/orgs/:customerId/{runs,registrations,workflows,held-runs,contexts,secrets,global-workflows}`
9299
9471
  - **Webhooks & event log:** `/orgs/:customerId/{sources,webhook-endpoints,event-log}`
9300
9472
  - **Diagnostics & activity:** `/orgs/:customerId/{diagnostics,activity,access-log}`
9301
9473
 
9474
+ **† 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.
9475
+
9476
+ `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.
9477
+
9302
9478
  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
9479
 
9304
9480
  ### Calling the API
@@ -9317,11 +9493,16 @@ curl -sS \
9317
9493
 
9318
9494
  **Browser console (after dashboard login):**
9319
9495
 
9496
+ Mint a personal access token and pass it explicitly. Do not script against the
9497
+ dashboard's own session token. That token is short-lived and tied to your
9498
+ identity-provider session, so anything built on it stops working at the next
9499
+ renewal or sign-out.
9500
+
9320
9501
  ```js
9321
- const ns = Object.keys(localStorage).find((k) => k.startsWith('oidc.user:'));
9322
- const { access_token } = JSON.parse(localStorage.getItem(ns));
9502
+ // kici pat create --name console --expires-in-days 1 → prints the token
9503
+ const token = 'kici_pat_...';
9323
9504
  const res = await fetch('/<deployment-slug>/api/v1/orgs/<your-org-id>/runs?limit=5', {
9324
- headers: { Authorization: `Bearer ${access_token}` },
9505
+ headers: { Authorization: `Bearer ${token}` },
9325
9506
  });
9326
9507
  console.log(await res.json());
9327
9508
  ```
@@ -9346,6 +9527,12 @@ The CLI stores authentication data in `~/.kici/config` with `0600` permissions (
9346
9527
  - Routing key for webhook source identification
9347
9528
  - API key, when you logged in with `--token`
9348
9529
 
9530
+ The web dashboard holds none of these. It keeps only the tokens of your current
9531
+ sign-in, in browser storage for the tab's origin. It does not request offline
9532
+ access, so those tokens die with your identity-provider session instead of
9533
+ staying valid for weeks. Its origin also serves a Content-Security-Policy that
9534
+ restricts which scripts run and which hosts the page may contact.
9535
+
9349
9536
  ## Troubleshooting
9350
9537
 
9351
9538
  ### Browser doesn't open
@@ -9550,10 +9737,14 @@ kici init --private-registry https://npm.pkg.github.com/ \
9550
9737
  types/ # Directory for generated type declarations (kici types)
9551
9738
  package.json # Dependencies (@kici-dev/sdk)
9552
9739
  tsconfig.json # TypeScript configuration (includes types/**/*.d.ts)
9740
+ .gitignore # Keeps the generated types/ declarations untracked
9741
+ .kiciignore # Paths the workflow content hash does not cover
9553
9742
  AGENTS.md # LLM authoring context (skip with --no-agents-md)
9554
9743
  .kiciignore # Default exclusion patterns for test uploads
9555
9744
  ```
9556
9745
 
9746
+ 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.
9747
+
9557
9748
  `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
9749
 
9559
9750
  In interactive mode (TTY), `kici init` prompts you to:
@@ -9569,7 +9760,7 @@ In interactive mode (TTY), `kici init` prompts you to:
9569
9760
 
9570
9761
  **Standalone vs workspace integration:** by default `kici init` scaffolds a self-contained `.kici/` with its own `package.json`. When run inside a pnpm, npm, or yarn workspace, it offers an **integrate** option (or pass `--workspace`): `.kici/` joins the workspace, `@kici-dev/sdk` is added to your workspace-root `package.json`, and your workflows can `import` your other workspace packages (e.g. shared build or deploy utilities). In this mode there is no `.kici/package.json` — the workspace root manages dependencies, and the root install resolves the SDK. Your workflows resolve sibling packages through the workspace-root `node_modules`: under npm and yarn every workspace member is hoisted there automatically, while pnpm links only your root's declared dependencies, so under pnpm add the package you want to import to your workspace-root `dependencies` (this is how KiCI's own repository imports its packages from workflows). Pass `--standalone` to force the self-contained layout even inside a workspace. In CI / non-interactive runs the default is standalone; use `--workspace` to opt in explicitly. `--workspace` and `--standalone` are mutually exclusive, and `--workspace` errors if no workspace is found at or above the current directory.
9571
9762
 
9572
- **Development mode:** When `KICI_DEV=true` or `package.json` has `"kici": { "development": true }`, the generated `package.json` uses prerelease-compatible version ranges (`>=0.0.1-0`) so npm resolves Verdaccio's prerelease builds.
9763
+ **Development mode:** When `KICI_DEV=true` or `package.json` has `"kici": { "development": true }`, the generated `package.json` pins `@kici-dev/sdk` to the `latest` dist-tag so npm resolves Verdaccio's newest prerelease build.
9573
9764
 
9574
9765
  ### kici org
9575
9766
 
@@ -10094,6 +10285,14 @@ to see it. The bundle holds your CLI, Node and orchestrator versions, your
10094
10285
  redacted configuration, and your project's workflow and lock-file state. With
10095
10286
  `--run` it also holds the failing run's detail and logs.
10096
10287
 
10288
+ For every orchestrator the probe returned the bundle also records where that
10289
+ orchestrator's own config files live — the paths only, never the contents. An
10290
+ orchestrator that is offline is still listed, with no paths: the CLI reads them
10291
+ from the live connection, so a disconnected one has none to report.
10292
+ Each path is a host path as that orchestrator sees it. A container deployment
10293
+ names a file on the container host, which is not a file you can open from the
10294
+ machine that read the bundle.
10295
+
10097
10296
  ```bash
10098
10297
  kici report [options]
10099
10298
  ```
@@ -10507,6 +10706,18 @@ Secrets are always sourced from your real `.kici/` directory, not from the isola
10507
10706
 
10508
10707
  Pass `--in-place` to run against the real working directory instead — useful when you explicitly want in-tree execution. `--in-place` requires no git repository; the default isolated mode does, and fails with an actionable error pointing at `--in-place` when the directory is not a git repository.
10509
10708
 
10709
+ **If the trigger times out:**
10710
+
10711
+ The command waits up to 60 seconds for the local dev plane to create the run. If nothing appears in that window it stops and names the cause it read back from the plane:
10712
+
10713
+ | Message | What it means |
10714
+ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
10715
+ | `plane is not leader yet (election grace period)` | The plane had not finished electing itself. A single-machine plane elects within seconds; if it does not, read the plane log (`kici local logs`). |
10716
+ | `no kici.lock.json at <commit> in <path>` | The plane resolved no lock file for the commit the run packed, so nothing matched. Commit `.kici/kici.lock.json`, or make sure it is present in the working tree. |
10717
+ | `the plane recorded delivery <id> as "<status>"` | The plane processed the trigger and wrote down a terminal status for it without creating a run. The status names the stage that stopped. |
10718
+ | `no run appeared for delivery <id> — see <log>` | Neither the plane's cluster state nor its delivery record explained the timeout. The named log is the next place to look. |
10719
+ | `no run appeared for this trigger — the plane never accepted the webhook` | Every webhook POST came back without a delivery id, so the plane never queued the trigger. Check the plane is up (`kici local status`), then read the named log. |
10720
+
10510
10721
  **Examples:**
10511
10722
 
10512
10723
  ```bash
@@ -11245,7 +11456,7 @@ You declare a gate in your workflow with `approval`. It is available at three le
11245
11456
  - **Job** — hold the job before any of its steps run.
11246
11457
  - **Workflow** — hold the whole run before any job is dispatched.
11247
11458
 
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.
11459
+ 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
11460
 
11250
11461
  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
11462
 
@@ -11316,7 +11527,7 @@ approval: {
11316
11527
 
11317
11528
  | Field | Type | Description |
11318
11529
  | ----------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
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). |
11530
+ | `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
11531
  | `approvers` | `ApproverClause[]` | The AND list of `{ team }` / `{ user }` clauses. An empty list means "any approval-capable member". |
11321
11532
  | `reason` | `string` | A human-readable label shown in the dashboard queue and the held-for-approval status check. |
11322
11533
  | `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 +11706,7 @@ Available rules:
11495
11706
  - **Branch restrictions** — only allow specific branches to deploy.
11496
11707
  - **Required reviewer approvals** — gate the run on human sign-off.
11497
11708
  - **Wait timers** — delay execution for a fixed period.
11498
- - **Concurrency limits** — prevent collisions between parallel deployments.
11709
+ - **Concurrency limits** — cap how many jobs run against the context at once.
11499
11710
 
11500
11711
  <!-- /help:contexts-protection -->
11501
11712
 
@@ -11584,7 +11795,7 @@ job('deploy', {
11584
11795
 
11585
11796
  **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
11797
 
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.
11798
+ **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
11799
 
11589
11800
  **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
11801
 
@@ -11787,6 +11998,36 @@ The children of a matrix job count individually against the limit. A three-child
11787
11998
  matrix bound to a context with a limit of two dispatches two children and applies
11788
11999
  the strategy above to the third.
11789
12000
 
12001
+ A job counts against the limit from the moment it is dispatched until it
12002
+ finishes. It does not have to reach an agent first.
12003
+
12004
+ #### What the limit does and does not guarantee
12005
+
12006
+ A context concurrency limit is a **throughput control**. Treat it as a cap on
12007
+ how much work runs at once, not as a lock.
12008
+
12009
+ Two events that arrive in the same instant read the limit before either job is
12010
+ recorded, so each can be admitted. The window is short — the time between one
12011
+ read and one write — but it is real, and it grows with the number of
12012
+ orchestrator processes serving the context.
12013
+
12014
+ When a job must never run beside another copy of itself — a production deploy,
12015
+ a database migration — declare a workflow-level
12016
+ [concurrency group](https://docs.kici.dev/user/concurrency/) as well:
12017
+
12018
+ ```typescript
12019
+ export default workflow('deploy', {
12020
+ on: push({ branches: ['main'] }),
12021
+ concurrency: { group: () => 'deploy-prod', max: 1 },
12022
+ jobs: [/* ... */],
12023
+ });
12024
+ ```
12025
+
12026
+ That mechanism claims its slot inside a single database transaction, so two
12027
+ runs arriving together cannot both take it. The two are complementary: the
12028
+ context limit caps throughput across every workflow bound to the context, and
12029
+ the concurrency group serializes one workflow against itself.
12030
+
11790
12031
  ## Dashboard management
11791
12032
 
11792
12033
  ### Creating contexts
@@ -12093,7 +12334,7 @@ registries: [
12093
12334
  ## Security model
12094
12335
 
12095
12336
  - **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.
12337
+ - **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
12338
  - **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
12339
  - **Stderr is redacted.** If the install fails, the agent masks every token literal out of the surfaced stderr / stdout chunks before logging.
12099
12340
  - **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 +12370,7 @@ The dashboard JSON lives at `infra/terraform/modules/grafana/dashboards/install-
12129
12370
  ## See also
12130
12371
 
12131
12372
  - [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.
12373
+ - [Contexts](https://docs.kici.dev/user/contexts/) — protection rules (branch restrictions, required reviewers, minimum trust) that the install gate inherits.
12133
12374
  - [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
12375
 
12135
12376
  ---
@@ -12247,6 +12488,107 @@ and the step never holds signing credentials — the orchestrator mints and sign
12247
12488
  the token on the step's behalf from its own run records. Like `attestProvenance`,
12248
12489
  it is only available inside a running job step.
12249
12490
 
12491
+ ## ID-token claims and cloud trust policies
12492
+
12493
+ A cloud provider's OIDC trust policy decides which builds may assume a role. The
12494
+ token below is what your policy matches on, so read this section before you
12495
+ write one.
12496
+
12497
+ ### The claim set
12498
+
12499
+ | Claim | Value |
12500
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |
12501
+ | `iss` | Your orchestrator's provenance issuer |
12502
+ | `aud` | The audience you asked for |
12503
+ | `sub` | The build identity — see the two shapes below |
12504
+ | `repository` | `owner/repo` the run acted on |
12505
+ | `ref` | The branch or tag the run PRESENTS. For a pull request this is the **base** branch, not the contributor's branch |
12506
+ | `base_ref` | The same value as `ref`, named the way GitHub Actions names it |
12507
+ | `head_ref` | The pull request's HEAD branch; `''` for a non-PR run |
12508
+ | `head_repository` | `owner/repo` of the pull-request HEAD — the contributor's fork for a fork PR; `''` for a non-PR run |
12509
+ | `is_fork` | `'true'`, `'false'`, or `'unresolved'` |
12510
+ | `event_name` | The event that started the run (`push`, `pull_request:opened`, `schedule`, …) |
12511
+ | `trust_tier` | The resolved trust tier of the triggering actor, or `'unresolved'` |
12512
+ | `actor` | Provider login of the triggering actor |
12513
+ | `sha` | The run's commit |
12514
+ | `workflow_ref` | `<workflow name>@<sha>` |
12515
+ | `kici_run_id` / `kici_job_id` | The run and job this token was minted for |
12516
+ | `org_id` | Your organization id |
12517
+
12518
+ Every claim in the table is **always present**. A value the run did not resolve
12519
+ is `''` or `'unresolved'`, never omitted and never guessed. That matters: an
12520
+ absent claim makes a `StringEquals` condition pass, which would silently remove
12521
+ a constraint you wrote expecting it to be enforced.
12522
+
12523
+ ### The two `sub` shapes
12524
+
12525
+ ```
12526
+ push, tag, schedule, … repo:<owner/repo>:ref:<ref>:workflow:<workflow name>
12527
+ pull request, review repo:<owner/repo>:pull_request
12528
+ ```
12529
+
12530
+ The pull-request shape carries **no ref segment**, mirroring GitHub Actions. A
12531
+ pull request's `ref` is its base branch. So a ref-bearing subject would be
12532
+ identical for a fork pull request targeting `main` and a trusted push to `main`.
12533
+ A policy pinning that subject would hand your cloud role to any contributor who
12534
+ opened a pull request running the same workflow.
12535
+
12536
+ **A re-run keeps the shape of the run it repeats.** Re-running a pull-request
12537
+ run presents `repo:<owner/repo>:pull_request`, because it rebuilds the same
12538
+ commit from the same source. Its `event_name` claim still reads `rerun` — that
12539
+ claim says what started the run, while `sub` says which identity the run
12540
+ presents. A policy that pins the branch-shaped subject therefore does not match
12541
+ a re-run of a pull request, which is the same protection the first run gets.
12542
+
12543
+ ### A worked AWS trust policy
12544
+
12545
+ Pin `sub`, and pin the fork context too. `sub` alone tells you a pull request
12546
+ ran; it does not tell you whose code ran.
12547
+
12548
+ ```json
12549
+ {
12550
+ "Version": "2012-10-17",
12551
+ "Statement": [
12552
+ {
12553
+ "Effect": "Allow",
12554
+ "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/orch.example.com" },
12555
+ "Action": "sts:AssumeRoleWithWebIdentity",
12556
+ "Condition": {
12557
+ "StringEquals": {
12558
+ "orch.example.com:aud": "sts.amazonaws.com",
12559
+ "orch.example.com:sub": "repo:acme/app:ref:main:workflow:deploy",
12560
+ "orch.example.com:is_fork": "false",
12561
+ "orch.example.com:head_repository": "acme/app",
12562
+ "orch.example.com:trust_tier": "trusted"
12563
+ }
12564
+ }
12565
+ }
12566
+ ]
12567
+ }
12568
+ ```
12569
+
12570
+ This grants the role only to a run on `main` in `acme/app`, from code in that
12571
+ same repository, triggered by an actor your orchestrator resolved as trusted.
12572
+ A fork pull request fails on all three of the extra conditions, and a run whose
12573
+ context did not resolve fails too — `'unresolved'` matches none of them, so the
12574
+ policy fails closed.
12575
+
12576
+ To let a same-repo pull request assume the role, add a second statement pinning
12577
+ `"sub": "repo:acme/app:pull_request"` alongside `"is_fork": "false"` and
12578
+ `"head_repository": "acme/app"`.
12579
+
12580
+ ### Migrating an existing policy
12581
+
12582
+ If you already pin a ref-bearing `sub` for pull-request runs, that policy stops
12583
+ matching once you upgrade — which is the fix, because it was matching runs it
12584
+ should not have. Move it to `repo:<owner/repo>:pull_request` plus the fork
12585
+ conditions above. The same move covers a re-run of a pull request, which
12586
+ presents the pull-request subject too.
12587
+
12588
+ While you migrate, `KICI_OIDC_LEGACY_PR_SUB=1` on the orchestrator restores the
12589
+ old subject. It restores the collision with it, so treat it as a short bridge,
12590
+ not a setting. See [deprecations](https://docs.kici.dev/user/deprecations/).
12591
+
12250
12592
  ## Verifying an attestation
12251
12593
 
12252
12594
  Verify a bundle with the `kici verify-attestation` command. It establishes the
@@ -12872,6 +13214,8 @@ workflow('test', {
12872
13214
 
12873
13215
  When `max: 1` (default), runs are fully serialized within the group.
12874
13216
 
13217
+ `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.
13218
+
12875
13219
  ## Group key examples
12876
13220
 
12877
13221
  ### Deploy per environment
@@ -12931,6 +13275,14 @@ When a workflow has both `concurrency` and `context` protection rules:
12931
13275
 
12932
13276
  This means a run that passed approval won't need re-approval if it gets queued by concurrency.
12933
13277
 
13278
+ The two caps also differ in strength. A workflow-level concurrency group claims
13279
+ its slot inside a single database transaction, so two runs that arrive together
13280
+ cannot both take it. A context
13281
+ [concurrency limit](https://docs.kici.dev/user/contexts/#concurrency-limits) is a throughput control:
13282
+ simultaneous arrivals can each be admitted before either is recorded. Declare a
13283
+ concurrency group for anything that must never run beside itself, whatever the
13284
+ context limit says.
13285
+
12934
13286
  ## Cancelling queued runs
12935
13287
 
12936
13288
  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 +13493,17 @@ Store the secrets first with `kici-admin secret set`. Pasting a token straight
13141
13493
  into the workflow is rejected when the workflow is defined, because a token
13142
13494
  written into `.kici/` would be committed to your repository.
13143
13495
 
13496
+ The named context's protection rules run before the secret is read, exactly as
13497
+ they do for [git credentials](https://docs.kici.dev/user/patterns/git-credentials/#what-a-job-may-ask-for).
13498
+ A `prod:` reference from a branch the `prod` context restricts is refused, and
13499
+ the job is dispatched with no registry credentials — so a private image fails to
13500
+ pull rather than being pulled from a branch the context does not allow. The rule
13501
+ that refused it is named in your orchestrator's log, not in the run.
13502
+
13503
+ **An untrusted ref receives no registry credentials.** A fork pull request is
13504
+ dispatched without them, so a private base image fails to pull and a public one
13505
+ is unaffected. The run's reduced-privilege note says so.
13506
+
13144
13507
  The username is not a secret, so you may write it directly:
13145
13508
 
13146
13509
  ```typescript
@@ -13150,6 +13513,30 @@ auth: { username: 'ci-bot', tokenSecret: 'prod:REGISTRY_TOKEN' }
13150
13513
  Your orchestrator resolves these names at dispatch and sends only the resolved
13151
13514
  credentials to the agent. The agent never reads your secret store.
13152
13515
 
13516
+ ### Naming the registry
13517
+
13518
+ `auth` also takes a `registry` — the registry host the credentials belong to,
13519
+ such as `reg.internal:5000`.
13520
+
13521
+ With `image` it is optional, because KiCI reads the host off the image
13522
+ reference. With `dockerfile` it is **required**: the base image is named inside
13523
+ your Dockerfile, so there is nothing to read it from. A `dockerfile` job whose
13524
+ `auth` omits `registry` is refused when you define the workflow.
13525
+
13526
+ ```typescript
13527
+ container: {
13528
+ dockerfile: '.kici/ci.Dockerfile',
13529
+ auth: {
13530
+ registry: 'reg.internal:5000',
13531
+ usernameSecret: 'prod:REGISTRY_USER',
13532
+ tokenSecret: 'prod:REGISTRY_TOKEN',
13533
+ },
13534
+ },
13535
+ ```
13536
+
13537
+ With `dockerfile`, these credentials pull the Dockerfile's own `FROM` base — not
13538
+ a job image, since the job image is the one KiCI builds.
13539
+
13153
13540
  ### Credentials that only exist at run time
13154
13541
 
13155
13542
  A token fetched during the run — from a cloud registry's login command, for
@@ -13300,10 +13687,11 @@ The KiCI CLI reads the following environment variables to customize its behavior
13300
13687
 
13301
13688
  ## Development
13302
13689
 
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 |
13690
+ | Variable | Description | Default |
13691
+ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
13692
+ | `KICI_DEV` | Enable development mode. When `true`, pins `@kici-dev/sdk` to the `latest` dist-tag (so a dev registry's prerelease build resolves) and skips npm version resolution. | unset |
13693
+ | `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 |
13694
+ | `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
13695
 
13308
13696
  ## Local dev plane
13309
13697
 
@@ -13637,7 +14025,7 @@ job('provision', {
13637
14025
 
13638
14026
  ### Trust tiers on internal triggers
13639
14027
 
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.
14028
+ 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
14029
 
13642
14030
  Four rules resolve the tier, and KiCI applies them in this order:
13643
14031
 
@@ -14164,7 +14552,7 @@ The registration log line names the organization it decided against, so a refusa
14164
14552
 
14165
14553
  ### Secrets are not available
14166
14554
 
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.
14555
+ 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
14556
 
14169
14557
  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
14558
 
@@ -15394,10 +15782,14 @@ kici-admin secret set <orgId> "__source__/<sourceId>" pat --value "<your-forgejo
15394
15782
  Finally, configure the forge to deliver webhooks to:
15395
15783
 
15396
15784
  ```
15397
- https://<platform-host>/webhook/<orgId>/generic/<source-name>
15785
+ https://<platform-host>/webhook/<orgId>/generic/<sourceId>
15398
15786
  ```
15399
15787
 
15400
- with the same secret you passed to `--secret`.
15788
+ with the same secret you passed to `--secret`. `<orgId>` is your Platform
15789
+ organization id — the same value you passed to `--org`, which the source's
15790
+ routing key embeds — and `<sourceId>` is the id `source add` printed. The
15791
+ orchestrator's own ingest URL has the same shape but takes the source **name**
15792
+ in place of the id.
15401
15793
 
15402
15794
  ## SSH deploy key
15403
15795
 
@@ -15591,7 +15983,7 @@ interface LocalConfig {
15591
15983
 
15592
15984
  ### SharedConfig
15593
15985
 
15594
- Shared settings stored in the PostgreSQL `config_versions` table. Defined once, shared across all instances:
15986
+ Settings stored in the PostgreSQL `config_versions` table, written and read by the `/admin/config` routes and their `kici-admin config` commands:
15595
15987
 
15596
15988
  ```typescript
15597
15989
  interface SharedConfig {
@@ -15635,11 +16027,13 @@ interface AppConfig {
15635
16027
 
15636
16028
  ### How they merge
15637
16029
 
16030
+ `resolveFullConfig()` takes a `LocalConfig` and an optional `SharedConfig` and merges them:
16031
+
15638
16032
  ```
15639
16033
  defaults (getDefaults())
15640
16034
  |
15641
16035
  v
15642
- SharedConfig (from DB) ──deepMerge──> merged layer 1+2
16036
+ SharedConfig (argument) ──deepMerge──> merged layer 1+2
15643
16037
  |
15644
16038
  v
15645
16039
  LocalConfig (from YAML) ──deepMerge──> merged layer 1+2+3
@@ -15656,29 +16050,48 @@ appConfigSchema.safeParse() ──validate──> typed AppConfig
15656
16050
 
15657
16051
  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
16052
 
16053
+ **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.
16054
+
15659
16055
  ## Resolution chain
15660
16056
 
15661
- ### Two-phase design
16057
+ ### Startup
16058
+
16059
+ `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
16060
 
15663
16061
  ```
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
16062
+ Process start
16063
+ |
16064
+ v
16065
+ loadConfig() -> envDef.parse(process.env) -> AppConfig
16066
+ |
16067
+ v
16068
+ Connect to PostgreSQL, run migrations
16069
+ |
16070
+ v
16071
+ Start server (HTTP, WS, scaler, cluster)
15679
16072
  ```
15680
16073
 
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).
16074
+ The database URL therefore has to be an environment variable: the orchestrator needs it to reach PostgreSQL, and the shared config lives in PostgreSQL.
16075
+
16076
+ ### Reload
16077
+
16078
+ `resolveLocalConfig()` and `resolveFullConfig()` run on the reload path, not at startup:
16079
+
16080
+ ```
16081
+ SIGHUP / POST /admin/config/reload / kici-admin config reload
16082
+ |
16083
+ v
16084
+ resolveLocalConfig() -> YAML file + KICI_ env overlay
16085
+ |
16086
+ v
16087
+ resolveFullConfig(local, null) -> defaults -> YAML -> env -> AppConfig
16088
+ |
16089
+ v
16090
+ Hold databaseUrl, port, instanceId and storage at their startup values
16091
+ |
16092
+ v
16093
+ Atomic swap into ConfigReloader.currentConfig
16094
+ ```
15682
16095
 
15683
16096
  ### Env var processing
15684
16097
 
@@ -15690,43 +16103,6 @@ Environment variables are processed in two stages:
15690
16103
 
15691
16104
  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
16105
 
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
16106
  ## DB schema
15731
16107
 
15732
16108
  ### config_versions table
@@ -15826,7 +16202,7 @@ flowchart TD
15826
16202
  trigger --> execute["executeReload()<br/>boolean mutex"]
15827
16203
 
15828
16204
  execute --> resolveLocal["resolveLocalConfig()"]
15829
- execute --> getLatest["getLatest (DB)"]
16205
+ execute --> getLatest["getLatest (DB)<br/>skipped: sharedStore is null"]
15830
16206
  execute --> resolveFull["resolveFullConfig()<br/>(merge + validate)"]
15831
16207
 
15832
16208
  resolveLocal --> check["Check restart-required fields"]
@@ -15845,26 +16221,26 @@ flowchart TD
15845
16221
  - **Mutex:** Boolean flag prevents concurrent reloads. Second reload returns `{ success: false, errors: ["Reload already in progress"] }`.
15846
16222
  - **Debounce:** Rapid triggers (e.g., multiple SIGHUP signals) are collapsed into a single reload with a 500ms window.
15847
16223
  - **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.
16224
+ - **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
16225
  - **No crash on failure:** The orchestrator always keeps running with the old config if anything goes wrong during reload.
15850
16226
 
15851
16227
  ### Subsystem callbacks
15852
16228
 
15853
16229
  The `ConfigReloader` uses a dependency injection pattern with callbacks for subsystem re-initialization:
15854
16230
 
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 |
16231
+ | Callback | When Called | Purpose |
16232
+ | --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
16233
+ | `onProviderChange` | Provider config changed | Reserved callback. Providers are DB-managed via the sources table, so the change detector always reports no change |
16234
+ | `onScalerReload` | Always on successful reload | Reload scaler YAML config, from the path the process started with |
16235
+ | `onPlatformReconnect` | Platform URL or token changed | Logs that the Platform connection settings changed. The connection is not re-established; `standalone.ts` registers no handler |
16236
+ | `onConfigApplied` | Always on successful reload | Atomic config reference swap, increment local config version |
15861
16237
 
15862
16238
  ### Prometheus metrics
15863
16239
 
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 |
16240
+ | Metric | Type | Labels | Description |
16241
+ | ------------------------------- | ------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
16242
+ | `kici_orch_config_reload_total` | Counter | `result` (attempted/success/failed), `source` (sighup/http/cluster/cli) | Config reload attempts and outcomes |
16243
+ | `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
16244
 
15869
16245
  ## Multi-Provider
15870
16246
 
@@ -15896,40 +16272,42 @@ Each source record contains its own `appId` and `privateKey` (stored as scoped s
15896
16272
 
15897
16273
  ### Heartbeat config version
15898
16274
 
15899
- In clustered deployments, each orchestrator includes its current config version in Raft heartbeat metadata via the `configVersion` optional field on the `peerHeartbeatSchema`.
16275
+ In clustered deployments, each orchestrator includes its config version in Raft heartbeat metadata via the `configVersion` optional field on the `peerHeartbeatSchema`.
16276
+
16277
+ **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
16278
 
15901
16279
  When the `PeerRegistry` processes a heartbeat:
15902
16280
 
15903
16281
  1. Compare `localConfigVersion` with `peer.configVersion`
15904
16282
  2. If `peer.configVersion > localConfigVersion` AND both are > 0:
15905
16283
  - Invoke the `onConfigVersionBehind` callback
15906
- - This triggers a config reload from the database
16284
+ - This triggers a config reload, which re-reads the environment and the local YAML file
15907
16285
 
15908
16286
  ### Auto-remediation flow
15909
16287
 
15910
16288
  ```
15911
- Orchestrator A (version 5) Orchestrator B (version 3)
16289
+ Orchestrator A (reloaded 5x) Orchestrator B (reloaded 3x)
15912
16290
  │ │
15913
16291
  │──── heartbeat(configVersion=5) ────>│
15914
16292
  │ │
15915
16293
  │ compare: 5 > 3
15916
- │ trigger reload from DB
16294
+ │ trigger reload
15917
16295
  │ │
15918
- │ resolveFullConfig()
15919
- │ -> version 5
16296
+ │ resolveFullConfig(local, null)
16297
+ │ -> counter becomes 4
15920
16298
  │ │
15921
- │<── heartbeat(configVersion=5) ──────│
16299
+ │<── heartbeat(configVersion=4) ──────│
15922
16300
  │ │
15923
- │ both at version 5 ✓ │
15924
16301
  ```
15925
16302
 
16303
+ 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.
16304
+
15926
16305
  ### Guard conditions
15927
16306
 
15928
16307
  - Version comparison only triggers when **both** local and peer versions are > 0
15929
16308
  - 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)
16309
+ - Orchestrators that do not report `configVersion` (field is optional, defaults to 0)
16310
+ - Newly started orchestrators before their first reload
15933
16311
 
15934
16312
  ## See also
15935
16313
 
@@ -15960,7 +16338,7 @@ GitHub --> Platform Relay --> Orchestrator --> Agent
15960
16338
 
15961
16339
  1. **Provider sends webhook** to the Platform relay endpoint.
15962
16340
  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).
16341
+ 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
16342
  4. **Orchestrator dedup check** against dual-layer `DedupCache` (in-memory set + `dedup_cache` DB table).
15965
16343
  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
16344
  6. **Orchestrator normalizes** the webhook via the provider's `WebhookNormalizer` (extracts branch, event type, action, sender).
@@ -16087,7 +16465,7 @@ Build Job Dispatch --> Build Agent (kici:role:builder + matching kici:os:/kici:a
16087
16465
  | |-- npm ci in .kici/
16088
16466
  | |-- Pack .kici/ source (portable tar.gz, excludes node_modules)
16089
16467
  | |-- Pack .kici/node_modules (portable tar.gz)
16090
- | |-- Upload source tarball to cache (source/{contentHash}.tar.gz)
16468
+ | |-- Upload source tarball to cache (source/v2/{orgId}/{sourceTarDigest}.tar.gz)
16091
16469
  | |-- Upload deps tarball to cache (deps/{plat}-{arch}/{depsHash}.tar.gz)
16092
16470
  | |-- Upload deps companion .hash file
16093
16471
  | |-- Report success (cache.upload.complete × 2)
@@ -16162,7 +16540,7 @@ Dep cache misses alone do **not** trigger a build job. Deps are platform-specifi
16162
16540
 
16163
16541
  ### Cross-source / no-contentHash workflows
16164
16542
 
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.
16543
+ - **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
16544
  - **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
16545
 
16168
16546
  ### Build deduplication
@@ -16201,7 +16579,7 @@ Both source and dep caches use `S3CacheStorage` as the sole backend. The `CacheS
16201
16579
 
16202
16580
  Cache keys reflect that source tarballs and deps have different platform characteristics:
16203
16581
 
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).
16582
+ - **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
16583
  - **Deps:** `deps/{platform}-{arch}/{depsHash}.tar.gz`, with a
16206
16584
  `deps/{platform}-{arch}/{lockfileHash}.hash` pointer holding that hash — the
16207
16585
  tarball is addressed by its own content, so two builds sharing a lock file
@@ -16932,14 +17310,14 @@ Shared business logic used by all three tiers. Single source of truth for cross-
16932
17310
  - Label utilities (platform label derivation, runsOn normalization, `kici:*` set-only reserved namespace, role labels)
16933
17311
  - 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
17312
  - 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
17313
+ - 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
17314
  - 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
17315
  - Registration trigger type enum (registerable trigger discriminator)
16938
17316
  - 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
17317
  - Plan tier vocabulary (the hosted plan tiers and the purchasable subset, shared by the Platform and the browser dashboard)
16940
17318
  - Infrastructure alert vocabulary (the diagnostics alert types and severities the Platform mints and the dashboard and `kici` CLI render)
16941
17319
  - 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)
17320
+ - 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
17321
 
16944
17322
  > Source: `packages/engine/src/`
16945
17323
 
@@ -16959,7 +17337,7 @@ It also runs the **local dev plane** -- an on-demand, fully local execution stac
16959
17337
 
16960
17338
  ### `@kici-dev/core`
16961
17339
 
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.
17340
+ 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
17341
 
16964
17342
  > Source: `packages/core/src/`
16965
17343
 
@@ -17041,7 +17419,7 @@ KiCI uses three WebSocket layers for real-time communication.
17041
17419
 
17042
17420
  ### Platform ↔ Orchestrator
17043
17421
 
17044
- The orchestrator connects outbound to the Platform WebSocket endpoint. After authentication (API key validated via SHA-256 hash lookup), the connection is used for webhook relay, execution telemetry (events, status, logs), source registration, and peer discovery. The Platform can also relay `job.reroute` messages between orchestrators that cannot reach each other directly.
17422
+ The orchestrator connects outbound to the Platform WebSocket endpoint. After authentication (API key validated via SHA-256 hash lookup), the connection is used for webhook relay, execution telemetry (events, status, logs), source registration, and peer discovery. Peer discovery is matchmaking only: the Platform pushes a `peer.update` membership list to every orchestrator sharing a routing key, and the orchestrators then connect to each other directly. Inter-orchestrator traffic such as `job.reroute` never transits the Platform.
17045
17423
 
17046
17424
  ### Orchestrator ↔ Orchestrator (P2P)
17047
17425