@kici-dev/compiler 0.1.14 → 0.1.16

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 (68) hide show
  1. package/README.md +21 -1
  2. package/dist/cli.d.ts +9 -1
  3. package/dist/cli.js +213 -179
  4. package/dist/commands/approve.d.ts +23 -0
  5. package/dist/commands/approve.js +46 -0
  6. package/dist/commands/compile.js +1 -1
  7. package/dist/commands/docs.js +2 -2
  8. package/dist/commands/endpoints.js +1 -1
  9. package/dist/commands/held-run-client.d.ts +26 -0
  10. package/dist/commands/held-run-client.js +98 -0
  11. package/dist/commands/held-run-resolve.d.ts +45 -0
  12. package/dist/commands/held-run-resolve.js +53 -0
  13. package/dist/commands/hook.js +1 -1
  14. package/dist/commands/index.d.ts +4 -0
  15. package/dist/commands/index.js +3 -1
  16. package/dist/commands/init.js +1 -1
  17. package/dist/commands/login.js +4 -15
  18. package/dist/commands/reject.d.ts +25 -0
  19. package/dist/commands/reject.js +49 -0
  20. package/dist/commands/run.js +13 -2
  21. package/dist/commands/secrets-list.js +1 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/test.d.ts +2 -0
  24. package/dist/commands/test.js +1 -1
  25. package/dist/commands/types.js +1 -1
  26. package/dist/commands/watch.js +1 -1
  27. package/dist/execution/executor.js +2 -2
  28. package/dist/execution/sdk-alias.js +1 -1
  29. package/dist/fixtures/compiler.js +2 -2
  30. package/dist/fixtures/defaults/index.js +2 -2
  31. package/dist/hooks/detector.js +1 -1
  32. package/dist/hooks/installer.js +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/llm-context/llms-full.txt +1141 -349
  35. package/dist/llm-context/llms.txt +4 -2
  36. package/dist/local-executor/index.js +5 -4
  37. package/dist/local-executor/job-runner.js +3 -5
  38. package/dist/local-executor/materializer.d.ts +5 -0
  39. package/dist/local-executor/materializer.js +23 -2
  40. package/dist/local-executor/secret-loader.js +1 -1
  41. package/dist/local-executor/to-event-payload.d.ts +16 -0
  42. package/dist/local-executor/to-event-payload.js +21 -0
  43. package/dist/local-executor/workflow-lock.d.ts +4 -3
  44. package/dist/local-executor/workflow-lock.js +0 -0
  45. package/dist/lockfile/generator.js +22 -5
  46. package/dist/lockfile/hash-files.js +1 -1
  47. package/dist/postinstall.js +1 -1
  48. package/dist/remote/client.d.ts +4 -0
  49. package/dist/remote/config.js +1 -1
  50. package/dist/remote/history.js +1 -1
  51. package/dist/remote/prod-defaults.d.ts +18 -0
  52. package/dist/remote/prod-defaults.js +23 -0
  53. package/dist/remote/secret-upload.d.ts +20 -0
  54. package/dist/remote/secret-upload.js +58 -0
  55. package/dist/remote/uploader.js +1 -1
  56. package/dist/templates/index.js +1 -1
  57. package/dist/templates/package-json.js +1 -1
  58. package/dist/templates/workflows/pr-checks.js +3 -2
  59. package/dist/templates/workflows/pr-checks.ts +4 -2
  60. package/dist/test-runner/git-detector.js +1 -1
  61. package/dist/test-runner/job-executor.js +2 -2
  62. package/dist/test-runner/secrets-file.js +1 -1
  63. package/dist/test-runner/step-context.js +6 -2
  64. package/dist/types.d.ts +33 -2
  65. package/dist/types.js +1 -0
  66. package/dist/workflows/pr-checks.ts +4 -2
  67. package/package.json +13 -9
  68. package/sbom.spdx.json +34 -34
@@ -12,7 +12,7 @@ Documentation for workflow authors -- people writing CI/CD pipelines in TypeScri
12
12
 
13
13
  ## Pages
14
14
 
15
- ### [Getting started](getting-started.md)
15
+ ### [Getting started with KiCI](getting-started.md)
16
16
 
17
17
  Install the SDK and compiler, write your first workflow, compile it to a lock file, and test it locally with simulated events. Covers prerequisites (Node.js 24+, pnpm), the `kici init` command for scaffolding, and the relationship between workflows, the lock file, and the three-tier runtime.
18
18
 
@@ -86,7 +86,7 @@ Cross-repo workflows that let a single workflow repo define jobs which run on ev
86
86
 
87
87
  ---
88
88
 
89
- ## Getting started with KiCI
89
+ ## Getting started with workflows
90
90
 
91
91
  Source: https://docs.kici.dev/user/getting-started/
92
92
 
@@ -179,7 +179,7 @@ Create `.kici/workflows/ci.ts`:
179
179
  import { workflow, job, step, pr } from '@kici-dev/sdk';
180
180
 
181
181
  const lint = job('lint', {
182
- runsOn: 'ubuntu-latest',
182
+ runsOn: 'linux',
183
183
  steps: [
184
184
  step('install', async ({ $ }) => {
185
185
  await $`pnpm install --frozen-lockfile`;
@@ -191,7 +191,7 @@ const lint = job('lint', {
191
191
  });
192
192
 
193
193
  const test = job('test', {
194
- runsOn: 'ubuntu-latest',
194
+ runsOn: 'linux',
195
195
  needs: [lint],
196
196
  steps: [
197
197
  step('install', async ({ $ }) => {
@@ -264,9 +264,9 @@ Workflow: ci
264
264
  ✓ Matched trigger 1
265
265
  Jobs (2):
266
266
  lint
267
- runs-on: ubuntu-latest
267
+ runs-on: linux
268
268
  test
269
- runs-on: ubuntu-latest
269
+ runs-on: linux
270
270
 
271
271
  Decision Summary:
272
272
 
@@ -310,7 +310,7 @@ This updates `.kici/package.json` and generates (or updates) `package-lock.json`
310
310
 
311
311
  ### Dependency resolution contract
312
312
 
313
- Every `.kici/` dependency must be resolvable from the **single cloned repository**. When a job runs, the agent clones only this repository and installs `.kici/` dependencies with your repo's package manager (npm or pnpm yarn is not yet supported and is rejected with an actionable error). A dependency that points outside the cloned repo cannot be resolved.
313
+ Every `.kici/` dependency must be resolvable from the **single cloned repository**. When a job runs, the agent clones only this repository and installs `.kici/` dependencies with your repo's package manager (npm or pnpm; yarn is not supported the agent rejects it with an actionable error). A dependency that points outside the cloned repo cannot be resolved.
314
314
 
315
315
  In practice:
316
316
 
@@ -525,7 +525,7 @@ A standard lint-then-test pipeline using job dependencies (`needs`):
525
525
  import { workflow, job, step, pr } from '@kici-dev/sdk';
526
526
 
527
527
  const lint = job('lint', {
528
- runsOn: 'ubuntu-latest',
528
+ runsOn: 'linux',
529
529
  steps: [
530
530
  step('install', async ({ $ }) => {
531
531
  await $`pnpm install --frozen-lockfile`;
@@ -538,7 +538,7 @@ const lint = job('lint', {
538
538
  });
539
539
 
540
540
  const test = job('test', {
541
- runsOn: 'ubuntu-latest',
541
+ runsOn: 'linux',
542
542
  needs: [lint],
543
543
  steps: [
544
544
  step('install', async ({ $ }) => {
@@ -551,7 +551,7 @@ const test = job('test', {
551
551
  });
552
552
 
553
553
  const typecheck = job('typecheck', {
554
- runsOn: 'ubuntu-latest',
554
+ runsOn: 'linux',
555
555
  needs: [lint],
556
556
  steps: [
557
557
  step('install', async ({ $ }) => {
@@ -608,7 +608,7 @@ const trigger = pr({
608
608
  });
609
609
 
610
610
  const build = job('build', {
611
- runsOn: 'ubuntu-latest',
611
+ runsOn: 'linux',
612
612
  steps: [
613
613
  step('build', async ({ $ }) => {
614
614
  await $`pnpm build`;
@@ -643,7 +643,7 @@ import { workflow, job, step, push } from '@kici-dev/sdk';
643
643
 
644
644
  // Deploy on pushes to main
645
645
  const deploy = job('deploy', {
646
- runsOn: 'ubuntu-latest',
646
+ runsOn: 'linux',
647
647
  steps: [
648
648
  step('deploy', async ({ $ }) => {
649
649
  await $`pnpm build`;
@@ -687,7 +687,7 @@ A workflow can respond to multiple trigger types:
687
687
  import { workflow, job, step, pr, push } from '@kici-dev/sdk';
688
688
 
689
689
  const test = job('test', {
690
- runsOn: 'ubuntu-latest',
690
+ runsOn: 'linux',
691
691
  steps: [
692
692
  step('test', async ({ $ }) => {
693
693
  await $`pnpm test`;
@@ -719,7 +719,7 @@ export default workflow('hello-world', {
719
719
  on: dispatch(),
720
720
  jobs: [
721
721
  job('greet', {
722
- runsOn: 'ubuntu-latest',
722
+ runsOn: 'linux',
723
723
  steps: [
724
724
  step('say-hello', async ({ $ }) => {
725
725
  await $`echo "Hello, World!"`;
@@ -777,7 +777,7 @@ Rules control whether a workflow or job runs. Use `rule()` for conditions that m
777
777
  import { workflow, job, step, pr, rule } from '@kici-dev/sdk';
778
778
 
779
779
  const test = job('test', {
780
- runsOn: 'ubuntu-latest',
780
+ runsOn: 'linux',
781
781
  steps: [
782
782
  step('test', async ({ $ }) => {
783
783
  await $`pnpm test`;
@@ -802,7 +802,7 @@ export default workflow('ci', {
802
802
  import { workflow, job, step, pr, rule, skip } from '@kici-dev/sdk';
803
803
 
804
804
  const unitTests = job('unit-tests', {
805
- runsOn: 'ubuntu-latest',
805
+ runsOn: 'linux',
806
806
  steps: [
807
807
  step('test', async ({ $ }) => {
808
808
  await $`pnpm test:unit`;
@@ -811,7 +811,7 @@ const unitTests = job('unit-tests', {
811
811
  });
812
812
 
813
813
  const e2eTests = job('e2e-tests', {
814
- runsOn: 'ubuntu-latest',
814
+ runsOn: 'linux',
815
815
  rules: [
816
816
  // Skip E2E when only docs change
817
817
  skip('docs only', async (ctx) => {
@@ -862,7 +862,7 @@ Run a job for each value in an array:
862
862
  import { workflow, job, step, push } from '@kici-dev/sdk';
863
863
 
864
864
  const test = job('test', {
865
- runsOn: 'ubuntu-latest',
865
+ runsOn: 'linux',
866
866
  matrix: ['18', '20', '22'],
867
867
  steps: [
868
868
  step('test', async ({ $, matrix }) => {
@@ -886,14 +886,14 @@ Use an object to define multiple dimensions. KiCI expands all combinations (capp
886
886
 
887
887
  ```typescript
888
888
  const test = job('test', {
889
- runsOn: 'ubuntu-latest',
889
+ runsOn: ['linux', 'kici:agent:container'],
890
890
  matrix: {
891
- os: ['ubuntu-latest', 'macos-latest'],
891
+ os: ['linux', 'arm64'],
892
892
  node: ['18', '20', '22'],
893
893
  },
894
894
  steps: [
895
895
  step('test', async ({ $, matrix }) => {
896
- // matrix.os = 'ubuntu-latest' | 'macos-latest'
896
+ // matrix.os = 'linux' | 'arm64'
897
897
  // matrix.node = '18' | '20' | '22'
898
898
  await $`echo "Testing on ${matrix!.os} with Node ${matrix!.node}"`;
899
899
  await $`pnpm test`;
@@ -904,21 +904,27 @@ const test = job('test', {
904
904
 
905
905
  This creates 6 job instances (2 OS x 3 Node versions).
906
906
 
907
+ > **Labels are customer-defined.** `runsOn` values such as `linux` or `arm64` are scaler
908
+ > labels **you** define in your orchestrator's `labelSets` — they are matched by subset
909
+ > semantics, not by a hosted-runner name. You can also target reserved auto-injected labels
910
+ > in the `kici:` namespace (e.g. `kici:agent:firecracker`, `kici:agent:container`) to pin a
911
+ > job to a specific backend type.
912
+
907
913
  ### Include and exclude
908
914
 
909
915
  Fine-tune matrix combinations:
910
916
 
911
917
  ```typescript
912
918
  const test = job('test', {
913
- runsOn: 'ubuntu-latest',
919
+ runsOn: 'linux',
914
920
  matrix: {
915
- os: ['ubuntu-latest', 'macos-latest', 'windows-latest'],
921
+ os: ['linux', 'arm64', 'windows'],
916
922
  node: ['18', '20', '22'],
917
923
  },
918
924
  // Remove specific combination
919
- exclude: [{ os: 'windows-latest', node: '18' }],
925
+ exclude: [{ os: 'windows', node: '18' }],
920
926
  // Add specific combination not in the matrix
921
- include: [{ os: 'ubuntu-latest', node: '23' }],
927
+ include: [{ os: 'linux', node: '23' }],
922
928
  steps: [
923
929
  step('test', async ({ $ }) => {
924
930
  await $`pnpm test`;
@@ -935,7 +941,7 @@ Compute matrix values at runtime using an async function:
935
941
 
936
942
  ```typescript
937
943
  const test = job('test', {
938
- runsOn: 'ubuntu-latest',
944
+ runsOn: 'linux',
939
945
  matrix: async ({ $ }) => {
940
946
  // Discover packages in a monorepo
941
947
  const result = await $`ls packages/`;
@@ -984,7 +990,7 @@ const discoverAndTest: DynamicJobFn = async ({ $ }) => {
984
990
 
985
991
  return packages.map((pkg) =>
986
992
  job(`test-${pkg}`, {
987
- runsOn: 'ubuntu-latest',
993
+ runsOn: 'linux',
988
994
  steps: [
989
995
  step('test', async ({ $ }) => {
990
996
  await $`cd packages/${pkg} && pnpm test`;
@@ -1006,7 +1012,7 @@ The `jobs` array accepts both static `Job` objects and `DynamicJobFn` functions:
1006
1012
 
1007
1013
  ```typescript
1008
1014
  const lint = job('lint', {
1009
- runsOn: 'ubuntu-latest',
1015
+ runsOn: 'linux',
1010
1016
  steps: [
1011
1017
  step('lint', async ({ $ }) => {
1012
1018
  await $`pnpm lint`;
@@ -1036,7 +1042,7 @@ const prTrigger = pr({ target: 'main', paths: ['src/**', 'packages/**', '!**/*.m
1036
1042
  const pushTrigger = push({ branches: 'main' });
1037
1043
 
1038
1044
  const lint = job('lint', {
1039
- runsOn: 'ubuntu-latest',
1045
+ runsOn: 'linux',
1040
1046
  steps: [
1041
1047
  step('install', async ({ $ }) => {
1042
1048
  await $`pnpm install --frozen-lockfile`;
@@ -1048,7 +1054,7 @@ const lint = job('lint', {
1048
1054
  });
1049
1055
 
1050
1056
  const test = job('test', {
1051
- runsOn: 'ubuntu-latest',
1057
+ runsOn: 'linux',
1052
1058
  needs: [lint],
1053
1059
  matrix: { node: ['18', '20', '22'] },
1054
1060
  steps: [
@@ -1059,7 +1065,7 @@ const test = job('test', {
1059
1065
  });
1060
1066
 
1061
1067
  const deploy = job('deploy', {
1062
- runsOn: 'ubuntu-latest',
1068
+ runsOn: 'linux',
1063
1069
  needs: [test],
1064
1070
  rules: [
1065
1071
  // Only deploy from push events (not PRs)
@@ -1114,7 +1120,7 @@ export const deploy = workflow('deploy', {
1114
1120
  on: push({ branches: 'main' }),
1115
1121
  jobs: [
1116
1122
  job('deploy', {
1117
- runsOn: 'ubuntu-latest',
1123
+ runsOn: 'linux',
1118
1124
  steps: [
1119
1125
  step('deploy', async ({ $ }) => {
1120
1126
  await $`./scripts/deploy.sh`;
@@ -1129,7 +1135,7 @@ export const postDeploy = workflow('post-deploy', {
1129
1135
  on: workflowComplete({ name: 'deploy', status: ['success'] }),
1130
1136
  jobs: [
1131
1137
  job('notify', {
1132
- runsOn: 'ubuntu-latest',
1138
+ runsOn: 'linux',
1133
1139
  steps: [
1134
1140
  step('slack', async ({ $ }) => {
1135
1141
  await $`./scripts/notify-slack.sh "Deploy succeeded"`;
@@ -1152,7 +1158,7 @@ export const deploy = workflow('deploy', {
1152
1158
  on: push({ branches: 'main' }),
1153
1159
  jobs: [
1154
1160
  job('deploy', {
1155
- runsOn: 'ubuntu-latest',
1161
+ runsOn: 'linux',
1156
1162
  steps: [
1157
1163
  step('deploy', async ({ $ }) => {
1158
1164
  await $`./scripts/deploy.sh`;
@@ -1173,7 +1179,7 @@ export const postDeploy = workflow('post-deploy', {
1173
1179
  on: kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }),
1174
1180
  jobs: [
1175
1181
  job('smoke-test', {
1176
- runsOn: 'ubuntu-latest',
1182
+ runsOn: 'linux',
1177
1183
  steps: [
1178
1184
  step('test', async ({ $ }) => {
1179
1185
  await $`./scripts/smoke-test.sh`;
@@ -1198,7 +1204,7 @@ export default workflow('on-argocd-deploy', {
1198
1204
  on: genericWebhook({ source: 'argocd', events: ['deploy.success'] }),
1199
1205
  jobs: [
1200
1206
  job('post-deploy', {
1201
- runsOn: 'ubuntu-latest',
1207
+ runsOn: 'linux',
1202
1208
  steps: [
1203
1209
  step('verify', async ({ $, rawPayload }) => {
1204
1210
  // rawPayload contains the full webhook body from ArgoCD
@@ -1232,7 +1238,7 @@ export default workflow('stripe-invoice-handler', {
1232
1238
  }),
1233
1239
  jobs: [
1234
1240
  job('process-invoice', {
1235
- runsOn: 'ubuntu-latest',
1241
+ runsOn: 'linux',
1236
1242
  steps: [
1237
1243
  step('extract-customer', async ({ $, log }) => {
1238
1244
  log.info('Processing paid invoice from Stripe');
@@ -1289,7 +1295,7 @@ export default workflow('on-forgejo-push', {
1289
1295
  }),
1290
1296
  jobs: [
1291
1297
  job('react-to-push', {
1292
- runsOn: 'ubuntu-latest',
1298
+ runsOn: 'linux',
1293
1299
  steps: [
1294
1300
  step('log', async ({ rawPayload, log }) => {
1295
1301
  const ref = (rawPayload as { ref?: string }).ref;
@@ -1311,7 +1317,7 @@ Manual-clone example (pattern 2) using an SSH deploy key:
1311
1317
 
1312
1318
  ```typescript
1313
1319
  job('forgejo-ci', {
1314
- runsOn: 'ubuntu-latest',
1320
+ runsOn: 'linux',
1315
1321
  checkout: false, // skip framework clone
1316
1322
  steps: [
1317
1323
  step('clone', async ({ $, ctx, rawPayload }) => {
@@ -1356,7 +1362,7 @@ kici-admin source add generic \
1356
1362
  --rate-limit 120
1357
1363
 
1358
1364
  # Patch the verificationConfig to use GitHub's signature header
1359
- # (the CLI does not expose --signature-header yet; use the admin REST API):
1365
+ # (the CLI has no --signature-header flag; use the admin REST API):
1360
1366
  curl -X PATCH https://<orchestrator>/api/v1/admin/generic-sources/<sourceId> \
1361
1367
  -H "Authorization: Bearer <admin-token>" \
1362
1368
  -H "Content-Type: application/json" \
@@ -1383,7 +1389,7 @@ export default workflow('on-github-repo-push', {
1383
1389
  }),
1384
1390
  jobs: [
1385
1391
  job('notify', {
1386
- runsOn: 'ubuntu-latest',
1392
+ runsOn: 'linux',
1387
1393
  checkout: false, // no App token -> skip auto-clone
1388
1394
  steps: [
1389
1395
  step('log', async ({ rawPayload, log }) => {
@@ -1502,7 +1508,7 @@ const install = step('install', async ({ $ }) => {
1502
1508
  });
1503
1509
 
1504
1510
  const fullTest = job('full-test', {
1505
- runsOn: 'ubuntu-latest',
1511
+ runsOn: 'linux',
1506
1512
  steps: [
1507
1513
  install,
1508
1514
  step('test', async ({ $ }) => {
@@ -1515,7 +1521,7 @@ const fullTest = job('full-test', {
1515
1521
  });
1516
1522
 
1517
1523
  const publish = job('publish-nightly', {
1518
- runsOn: 'ubuntu-latest',
1524
+ runsOn: 'linux',
1519
1525
  needs: [fullTest],
1520
1526
  steps: [
1521
1527
  install,
@@ -1559,7 +1565,7 @@ export const build = workflow('build', {
1559
1565
  on: push({ branches: 'main' }),
1560
1566
  jobs: [
1561
1567
  job('test', {
1562
- runsOn: 'ubuntu-latest',
1568
+ runsOn: 'linux',
1563
1569
  steps: [
1564
1570
  step('install', async ({ $ }) => {
1565
1571
  await $`pnpm install --frozen-lockfile`;
@@ -1580,7 +1586,7 @@ export const deploy = workflow('deploy-on-success', {
1580
1586
  on: workflowComplete({ name: 'build', status: ['success'] }),
1581
1587
  jobs: [
1582
1588
  job('deploy', {
1583
- runsOn: 'ubuntu-latest',
1589
+ runsOn: 'linux',
1584
1590
  steps: [
1585
1591
  step('deploy-staging', async ({ $ }) => {
1586
1592
  await $`./scripts/deploy.sh staging`;
@@ -1627,7 +1633,7 @@ export const testSuite = workflow('test-suite', {
1627
1633
  on: push({ branches: 'main' }),
1628
1634
  jobs: [
1629
1635
  job('test', {
1630
- runsOn: 'ubuntu-latest',
1636
+ runsOn: 'linux',
1631
1637
  steps: [
1632
1638
  step('install', async ({ $ }) => {
1633
1639
  await $`pnpm install --frozen-lockfile`;
@@ -1653,7 +1659,7 @@ export const autoDeploy = workflow('auto-deploy', {
1653
1659
  on: kiciEvent({ name: 'tests-passed' }),
1654
1660
  jobs: [
1655
1661
  job('deploy', {
1656
- runsOn: 'ubuntu-latest',
1662
+ runsOn: 'linux',
1657
1663
  steps: [
1658
1664
  step('deploy', async ({ $ }) => {
1659
1665
  await $`./scripts/deploy.sh`;
@@ -1682,6 +1688,149 @@ export const autoDeploy = workflow('auto-deploy', {
1682
1688
 
1683
1689
  # SDK reference
1684
1690
 
1691
+ ## Caching
1692
+
1693
+ Source: https://docs.kici.dev/user/sdk/caching/
1694
+
1695
+ KiCI ships a general-purpose cache for any files or directories your workflow produces — compiled artifacts, downloaded toolchains, package manager stores, build outputs. A cache entry is keyed, immutable once written, and shared across runs of the same repository so a later run can restore what an earlier run produced instead of recomputing it.
1696
+
1697
+ Two surfaces drive the same cache:
1698
+
1699
+ - **Declarative** — a `cache` field on a job or a step. The runtime restores before the work runs and saves after it succeeds, with no code in your step body.
1700
+ - **Imperative** — `ctx.cache.restore(spec)` / `ctx.cache.save(spec)` inside a step body, for fine-grained control over when restore and save happen.
1701
+
1702
+ The cache is backed by the orchestrator's object storage. Entries are isolated per organization and per ref scope (see [Isolation](#isolation)); no other tenant can read your cache, and an untrusted/fork ref can never poison the cache a trusted branch reads.
1703
+
1704
+ ## CacheSpec
1705
+
1706
+ Both surfaces take the same shape:
1707
+
1708
+ ```typescript
1709
+ interface CacheSpec {
1710
+ /** Exact cache key. First save wins; re-saving an existing key is a no-op. */
1711
+ key: string;
1712
+ /** Files/directories to cache. Repo-root-relative or `~`-prefixed. */
1713
+ paths: string[];
1714
+ /** Ordered prefix fallbacks for partial restore; newest matching entry wins. */
1715
+ restoreKeys?: string[];
1716
+ }
1717
+ ```
1718
+
1719
+ - **`key`** is the exact cache key. It is **immutable** — the first save under a given key wins, and any later save under the same exact key is a no-op (the existing entry is never overwritten). Build keys from inputs that change when the cached content should change, e.g. a hash of your lockfile: `` key: `deps-${await ctx.$`sha256sum pnpm-lock.yaml`}` ``.
1720
+ - **`paths`** are the files and directories to archive, repo-root-relative or `~`-prefixed (the agent expands `~` to the workspace home). At least one path is required.
1721
+ - **`restoreKeys`** are ordered **prefix** fallbacks tried only when the exact `key` misses on restore. Each prefix is matched against existing entries; the **newest** matching entry wins. This lets a run that changed its lockfile still restore the closest previous cache and rebuild incrementally.
1722
+
1723
+ ## Declarative cache
1724
+
1725
+ Add a `cache` field to a job or a step. It accepts one `CacheSpec` or an array of them. The runtime restores every spec before the job/step runs (surfaced as a `cache:restore` pseudo-step) and saves every spec after it completes successfully (surfaced as a `cache:save` pseudo-step):
1726
+
1727
+ ```typescript
1728
+ import { job } from '@kici-dev/sdk';
1729
+
1730
+ job('build', {
1731
+ runsOn: 'linux-x64',
1732
+ cache: {
1733
+ key: 'mise-tools-v1',
1734
+ paths: ['~/.local/share/mise'],
1735
+ },
1736
+ steps: [
1737
+ step('install-tools', async (ctx) => {
1738
+ await ctx.$`mise install`;
1739
+ }),
1740
+ step('build', async (ctx) => {
1741
+ await ctx.$`mise exec -- pnpm build`;
1742
+ }),
1743
+ ],
1744
+ });
1745
+ ```
1746
+
1747
+ Step-level cache scopes the restore/save to a single step:
1748
+
1749
+ ```typescript
1750
+ step('deps', {
1751
+ cache: { key: `npm-${lockfileHash}`, paths: ['node_modules'], restoreKeys: ['npm-'] },
1752
+ run: async (ctx) => {
1753
+ await ctx.$`pnpm install --frozen-lockfile`;
1754
+ },
1755
+ });
1756
+ ```
1757
+
1758
+ On a cache **hit**, the archived paths are restored before the step body runs, so `pnpm install` sees a warm `node_modules`. On a **miss**, the step runs cold and the resulting paths are saved under the exact key for the next run.
1759
+
1760
+ ## Imperative cache (`ctx.cache`)
1761
+
1762
+ When you need to decide at runtime whether to restore or save — for example, save only when a build actually changed something — use the imperative API on the step context:
1763
+
1764
+ ```typescript
1765
+ step('build', async (ctx) => {
1766
+ const result = await ctx.cache.restore({
1767
+ key: `build-${sourceHash}`,
1768
+ paths: ['dist'],
1769
+ restoreKeys: ['build-'],
1770
+ });
1771
+
1772
+ if (result.hit) {
1773
+ ctx.log.info(`restored cache (matched ${result.matchedKey})`);
1774
+ }
1775
+
1776
+ await ctx.$`pnpm build`;
1777
+
1778
+ await ctx.cache.save({ key: `build-${sourceHash}`, paths: ['dist'] });
1779
+ });
1780
+ ```
1781
+
1782
+ `restore(spec)` returns `{ hit, matchedKey? }`:
1783
+
1784
+ - `hit` is `true` when the exact `key` matched **or** a `restoreKeys` prefix matched.
1785
+ - `matchedKey` is the full key that actually matched — the exact key on a direct hit, or the full key of the matched prefix entry on a fallback hit.
1786
+
1787
+ `save(spec)` archives `spec.paths` under `spec.key`. Like the declarative surface, it is immutable: the first save under an exact key wins, and re-saving the same key is a no-op.
1788
+
1789
+ ## Restore semantics
1790
+
1791
+ A restore resolves in this order:
1792
+
1793
+ 1. **Exact key.** If an entry exists under the exact `key`, it is restored and `matchedKey === key`.
1794
+ 2. **restoreKeys prefix fallback.** Each `restoreKeys` prefix is tried in order. Within a prefix, the **newest** matching entry wins; `matchedKey` is that entry's full key.
1795
+ 3. **Miss.** If nothing matches, `hit` is `false` and no paths are restored.
1796
+
1797
+ This mirrors the familiar lockfile-hash pattern: key the entry on the exact lockfile hash, and add a `restoreKeys` prefix so a changed lockfile still restores the most recent prior cache to rebuild from.
1798
+
1799
+ ## Immutability
1800
+
1801
+ Cache keys are write-once. The **first** save under an exact key wins; every subsequent save under that same exact key is a no-op and the original bytes are preserved. To publish new content, use a new key (typically by including a content hash in the key). Immutability is what makes a cache hit safe to trust — the bytes behind a given key never change after they are first written.
1802
+
1803
+ ## Isolation
1804
+
1805
+ Each cache entry is scoped to your organization and to the ref's trust level:
1806
+
1807
+ - **Trusted refs** (your repository's own branches, default branch) read and write a **shared** scope visible to the whole org for that repository.
1808
+ - **Untrusted / fork refs** read the shared scope as a fallback but write to an **isolated** per-run scope. A fork build can therefore benefit from a warm cache the trusted branch produced, but can never write into the shared scope — so a malicious fork cannot poison the cache a trusted branch later restores.
1809
+
1810
+ No tenant can read another tenant's cache; the org boundary is enforced in the cache key namespace.
1811
+
1812
+ ## Eviction
1813
+
1814
+ Cache storage is bounded per organization. Two mechanisms keep it bounded:
1815
+
1816
+ - **Quota** — when a save pushes the org over its byte quota (`KICI_USER_CACHE_QUOTA_BYTES`, default 5 GiB), the oldest entries are evicted until the org is back under quota.
1817
+ - **TTL** — entries unused for `KICI_USER_CACHE_TTL_MS` (default 7 days) expire. The TTL refreshes on read (touch-on-read), so an actively used cache stays warm.
1818
+
1819
+ Both knobs are operator-configured on the orchestrator — see [orchestrator storage layout](../../operator/orchestrator/storage-layout.md).
1820
+
1821
+ ## Observability
1822
+
1823
+ Each cache restore and save surfaces in the run timeline as a `cache:restore` / `cache:save` pseudo-step, reporting the outcome (hit/miss/saved, the matched key, bytes). The same outcomes are recorded as `cache.restore` / `cache.save` run events. See [data flows](../../architecture/data-flows.md#user-facing-cache-flow) for the restore/save protocol.
1824
+
1825
+ ## See also
1826
+
1827
+ - [Core](./core.md) -- `job()` / `step()` factories the `cache` field attaches to
1828
+ - [Runtime](./runtime.md) -- `StepContext`, where `ctx.cache` lives
1829
+ - [Orchestrator storage layout](../../operator/orchestrator/storage-layout.md) -- cache prefix, quota, TTL, and eviction
1830
+ - [Data flows](../../architecture/data-flows.md#user-facing-cache-flow) -- restore/save protocol and trust→scope mapping
1831
+
1832
+ ---
1833
+
1685
1834
  ## SDK reference: core
1686
1835
 
1687
1836
  Source: https://docs.kici.dev/user/sdk/core/
@@ -1698,21 +1847,22 @@ function workflow(name: string, options: WorkflowOptions): Workflow;
1698
1847
 
1699
1848
  **Parameters:**
1700
1849
 
1701
- | Parameter | Type | Required | Description |
1702
- | --------------------- | ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
1703
- | `name` | `string` | yes | Unique workflow name |
1704
- | `options.jobs` | `JobOrFactory[]` | yes | Static jobs and/or dynamic job generators |
1705
- | `options.on` | `Trigger \| Trigger[]` | no | When the workflow should trigger |
1706
- | `options.rules` | `Rule[]` | no | Conditions that must pass for execution |
1707
- | `options.description` | `string` | no | Human-readable description |
1708
- | `options.hashFiles` | `string[]` | no | Extra repo-relative paths or globs mixed into the workflow content hash. Changes invalidate the source cache. |
1709
- | `options.registries` | `Registry[]` | no | Private npm registries the agent authenticates against before `npm install`. Each `tokenSecret` uses qualified `<environment>:<secret>` syntax. |
1710
- | `options.installEnv` | `string[]` | no | Qualified `<environment>:<secret>` refs projected as env vars onto the install subprocess (used with a customer-committed `.kici/.npmrc`). |
1711
- | `options.onCancel` | `HookInput` | no | Runs when the workflow is cancelled |
1712
- | `options.cleanup` | `HookInput` | no | Always runs after the workflow (success, failure, or cancel) |
1713
- | `options.onSuccess` | `HookInput` | no | Runs on workflow success |
1714
- | `options.onFailure` | `HookInput` | no | Runs on workflow failure |
1715
- | `options.concurrency` | `{ group: (ctx) => string; cancelInProgress?: boolean; max?: number }` | no | Workflow-scoped concurrency. See [Concurrency](../concurrency.md). |
1850
+ | Parameter | Type | Required | Description |
1851
+ | --------------------- | ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
1852
+ | `name` | `string` | yes | Unique workflow name |
1853
+ | `options.jobs` | `JobOrFactory[]` | yes | Static jobs and/or dynamic job generators |
1854
+ | `options.on` | `Trigger \| Trigger[]` | no | When the workflow should trigger |
1855
+ | `options.rules` | `Rule[]` | no | Conditions that must pass for execution |
1856
+ | `options.description` | `string` | no | Human-readable description |
1857
+ | `options.hashFiles` | `string[]` | no | Extra repo-relative paths or globs mixed into the workflow content hash. Changes invalidate the source cache. |
1858
+ | `options.registries` | `Registry[]` | no | Private npm registries the agent authenticates against before `npm install`. Each `tokenSecret` uses qualified `<environment>:<secret>` syntax. |
1859
+ | `options.installEnv` | `string[]` | no | Qualified `<environment>:<secret>` refs projected as env vars onto the install subprocess (used with a customer-committed `.kici/.npmrc`). |
1860
+ | `options.onCancel` | `HookInput` | no | Runs when the workflow is cancelled |
1861
+ | `options.cleanup` | `HookInput` | no | Always runs after the workflow (success, failure, or cancel) |
1862
+ | `options.onSuccess` | `HookInput` | no | Runs on workflow success |
1863
+ | `options.onFailure` | `HookInput` | no | Runs on workflow failure |
1864
+ | `options.concurrency` | `{ group: (ctx) => string; cancelInProgress?: boolean; max?: number }` | no | Workflow-scoped concurrency. See [Concurrency](../concurrency.md). |
1865
+ | `options.timeout` | `number` | no | Whole-run wall-clock timeout in milliseconds across all jobs. On breach the orchestrator cancels the run and marks it timed out. See [Timeouts](#timeouts). |
1716
1866
 
1717
1867
  **Returns:** `Workflow` -- an immutable workflow definition.
1718
1868
 
@@ -1738,45 +1888,47 @@ function job(options: JobOptions): Job;
1738
1888
 
1739
1889
  **Parameters:**
1740
1890
 
1741
- | Parameter | Type | Required | Description |
1742
- | -------------------------- | --------------------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------- |
1743
- | `name` | `string` | no | Job name (auto-generated UUID if omitted) |
1744
- | `options.runsOn` | `RunsOn` | yes | Runner label(s) and optional exclusions (see below) |
1745
- | `options.steps` | `StepInput[]` | yes (or use `run`) | Steps to execute in order. Mutually exclusive with `run`. |
1746
- | `options.run` | `(ctx) => Promise<unknown>` | yes (or use `steps`) | Single-step shorthand -- see [Single-step job shorthand](#single-step-job-shorthand). Mutually exclusive with `steps`. |
1747
- | `options.needs` | `NeedsEntry[]` | no | Job dependencies (must complete first) -- see [Job dependencies (`needs`)](#job-dependencies-needs) |
1748
- | `options.rules` | `Rule[]` | no | Conditions for conditional execution |
1749
- | `options.description` | `string` | no | Human-readable description |
1750
- | `options.matrix` | `Matrix` | no | Matrix configuration for job expansion |
1751
- | `options.include` | `MatrixInclude[]` | no | Additional matrix combinations |
1752
- | `options.exclude` | `MatrixExclude[]` | no | Matrix combinations to remove |
1753
- | `options.checkout` | `boolean` | no (default: `true`) | When `false`, agent skips git clone. Useful for deploy/notify jobs. |
1754
- | `options.container` | `string \| ContainerConfig` | no | Docker image for job execution. String form is the image name; object form adds `env`. All steps run inside the container. |
1755
- | `options.environment` | `string \| ((event) => string \| Promise<string>)` | no | Deployment environment for this job. Static string or async/dynamic function -- see [Dynamic values](../dynamic-values.md). |
1756
- | `options.env` | `Record<string, string> \| ((event) => Record<string, string>)` | no | Environment variables. Static object or async/dynamic function -- see [Dynamic values](../dynamic-values.md). |
1757
- | `options.concurrencyGroup` | `string \| ((event) => string \| Promise<string>)` | no | Concurrency group name (defaults to environment name) -- see [Concurrency](../concurrency.md). |
1758
- | `options.onCancel` | `HookInput` | no | Hook that runs when the job is cancelled |
1759
- | `options.cleanup` | `HookInput` | no | Hook that always runs after completion |
1760
- | `options.onSuccess` | `HookInput` | no | Hook that runs when the job succeeds |
1761
- | `options.onFailure` | `HookInput` | no | Hook that runs when the job fails |
1762
- | `options.beforeStep` | `HookInput` | no | Hook that runs before each step |
1763
- | `options.afterStep` | `HookInput` | no | Hook that runs after each step |
1764
- | `options.gracePeriod` | `number` | no | Seconds before SIGKILL after SIGTERM during cancellation -- see [Hooks](../hooks.md#hook-timeout). |
1765
- | `options.resources` | `ResourceRequest` | no | Per-job CPU / memory request and limit. See [Per-job resources](#per-job-resources) below. |
1891
+ | Parameter | Type | Required | Description |
1892
+ | -------------------------- | --------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
1893
+ | `name` | `string` | no | Job name (auto-generated UUID if omitted) |
1894
+ | `options.runsOn` | `RunsOn` | yes | Runner label(s) and optional exclusions (see below) |
1895
+ | `options.steps` | `StepInput[]` | yes (or use `run`) | Steps to execute in order. Mutually exclusive with `run`. |
1896
+ | `options.run` | `(ctx) => Promise<unknown>` | yes (or use `steps`) | Single-step shorthand -- see [Single-step job shorthand](#single-step-job-shorthand). Mutually exclusive with `steps`. |
1897
+ | `options.needs` | `NeedsEntry[]` | no | Job dependencies (must complete first) -- see [Job dependencies (`needs`)](#job-dependencies-needs) |
1898
+ | `options.rules` | `Rule[]` | no | Conditions for conditional execution |
1899
+ | `options.description` | `string` | no | Human-readable description |
1900
+ | `options.matrix` | `Matrix` | no | Matrix configuration for job expansion |
1901
+ | `options.include` | `MatrixInclude[]` | no | Additional matrix combinations |
1902
+ | `options.exclude` | `MatrixExclude[]` | no | Matrix combinations to remove |
1903
+ | `options.checkout` | `boolean` | no (default: `true`) | When `false`, agent skips git clone. Useful for deploy/notify jobs. |
1904
+ | `options.container` | `string \| ContainerConfig` | no | Docker image for job execution. String form is the image name; object form adds `env`. All steps run inside the container. |
1905
+ | `options.environment` | `string \| ((event) => string \| Promise<string>)` | no | Deployment environment for this job. Static string or async/dynamic function -- see [Dynamic values](../dynamic-values.md). |
1906
+ | `options.env` | `Record<string, string> \| ((event) => Record<string, string>)` | no | Environment variables. Static object or async/dynamic function -- see [Dynamic values](../dynamic-values.md). |
1907
+ | `options.concurrencyGroup` | `string \| ((event) => string \| Promise<string>)` | no | Concurrency group name (defaults to environment name) -- see [Concurrency](../concurrency.md). |
1908
+ | `options.onCancel` | `HookInput` | no | Hook that runs when the job is cancelled |
1909
+ | `options.cleanup` | `HookInput` | no | Hook that always runs after completion |
1910
+ | `options.onSuccess` | `HookInput` | no | Hook that runs when the job succeeds |
1911
+ | `options.onFailure` | `HookInput` | no | Hook that runs when the job fails |
1912
+ | `options.beforeStep` | `HookInput` | no | Hook that runs before each step |
1913
+ | `options.afterStep` | `HookInput` | no | Hook that runs after each step |
1914
+ | `options.gracePeriod` | `number` | no | Seconds before SIGKILL after SIGTERM during cancellation -- see [Hooks](../hooks.md#hook-timeout). |
1915
+ | `options.timeout` | `number` | no | Total job wall-clock timeout in milliseconds (init + all steps + hooks). On breach the job is aborted and reported timed out. See [Timeouts](#timeouts). |
1916
+ | `options.resources` | `ResourceRequest` | no | Per-job CPU / memory request and limit. See [Per-job resources](#per-job-resources) below. |
1917
+ | `options.init` | `GenericInitConfig \| GenericInitConfig[] \| false` | no | Per-job initialization run after clone, before steps -- provisions a toolchain. See [Per-job init](#per-job-init) below. |
1766
1918
 
1767
1919
  **Returns:** `Job` -- an immutable job definition.
1768
1920
 
1769
1921
  ```typescript
1770
1922
  // Named job
1771
1923
  const build = job('build', {
1772
- runsOn: 'ubuntu-latest',
1924
+ runsOn: 'linux',
1773
1925
  steps: [checkout, install, compile],
1774
1926
  needs: [lint],
1775
1927
  });
1776
1928
 
1777
1929
  // Anonymous job (auto-generated UUID name)
1778
1930
  const build = job({
1779
- runsOn: 'ubuntu-latest',
1931
+ runsOn: 'linux',
1780
1932
  steps: [checkout, install],
1781
1933
  });
1782
1934
  ```
@@ -1802,7 +1954,7 @@ runsOn: { labels: ['linux', 'docker'], exclude: ['gpu'] }
1802
1954
  - **Required labels:** The agent must have every label in the `labels` array (or the string/array form).
1803
1955
  - **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.
1804
1956
  - **Compile-time validation:** The compiler will error if any label appears in both `labels` and `exclude` (overlap detection).
1805
- - **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 operator guide](/operator/orchestrator/auto-scaler/overview-and-config/#mandatory-labels-k8s-style-opt-in-gate) for details.
1957
+ - **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](../../operator/orchestrator/auto-scaler/common-config.md#mandatory--exclude-labels) for details.
1806
1958
 
1807
1959
  ```typescript
1808
1960
  // Route to any Linux agent that does NOT have the 'gpu' label
@@ -1899,6 +2051,65 @@ Per-backend kernel enforcement of `limits`:
1899
2051
  - **Firecracker backend:** always enforced. Fractional CPU rounds up to the nearest integer vCPU.
1900
2052
  - **Bare-metal backend:** advisory by default — the scaler caps still apply, but no cgroup is created. Operators can opt in to kernel enforcement via `enforceCgroups: true` on the scaler entry.
1901
2053
 
2054
+ ### Per-job init
2055
+
2056
+ `options.init` declares a hand-written command that runs **after the repo is cloned and before the job's steps execute**. Its purpose is to provision a repo-declared toolchain (a `mise` toolchain, a custom setup script, a language runtime) and put it on the environment every subsequent step sees.
2057
+
2058
+ ```typescript
2059
+ import { workflow, job, step, push } from '@kici-dev/sdk';
2060
+
2061
+ export const build = workflow('build', {
2062
+ on: [push()],
2063
+ jobs: [
2064
+ job('build', {
2065
+ runsOn: 'linux',
2066
+ init: {
2067
+ run: `
2068
+ set -euo pipefail
2069
+ command -v mise >/dev/null || curl -fsSL https://mise.run | sh
2070
+ export PATH="$HOME/.local/bin:$PATH"
2071
+ mise install
2072
+ mise env -s bash | sed -n 's/^export //p' >> "$KICI_ENV"
2073
+ echo "$HOME/.local/share/mise/shims" >> "$KICI_PATH"
2074
+ `,
2075
+ cache: { key: 'mise-jq-1.7.1', paths: ['~/.local/share/mise'] },
2076
+ timeout: 600_000,
2077
+ },
2078
+ steps: [
2079
+ step('show-jq-version', async (ctx) => {
2080
+ // jq is on PATH because the init phase appended the mise shims dir to $KICI_PATH.
2081
+ const { stdout } = await ctx.$`jq --version`;
2082
+ ctx.log.info(`jq version: ${stdout.trim()}`);
2083
+ }),
2084
+ ],
2085
+ }),
2086
+ ],
2087
+ });
2088
+ ```
2089
+
2090
+ **`GenericInitConfig` shape:**
2091
+
2092
+ | Field | Type | Required | Description |
2093
+ | --------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
2094
+ | `run` | `string` | yes | Command run after clone, before steps. Runs in the job's sandbox at the clone root. Must be a non-empty command. |
2095
+ | `shell` | `string` | no | Shell used to run `run`. Defaults to `bash`. |
2096
+ | `cache` | `CacheSpec` | no | Cache spec for binaries the command installs -- restored before the command, saved after on a key miss. See [Caching](./caching.md). |
2097
+ | `timeout` | `number` | no | Max wall-clock for this init command in milliseconds. Defaults to 10 minutes. On breach the init is aborted and the job is reported timed out. |
2098
+ | `env` | `Record<string,string>` | no | Static environment variables available to the command. |
2099
+
2100
+ **The `$KICI_ENV` / `$KICI_PATH` handoff.** The init command does not mutate the step environment directly. Instead it writes what it wants visible to later steps to two files the agent allocates and exposes as environment variables:
2101
+
2102
+ - **`$KICI_ENV`** -- append one `KEY=value` line per environment variable. The agent reads the file after the command and makes each variable available to every subsequent step.
2103
+ - **`$KICI_PATH`** -- append one directory per line. The agent prepends each directory to `PATH` for every subsequent step.
2104
+
2105
+ The agent reads both files after the command succeeds, applies the delta, and the resulting environment is visible to all steps that follow (and to any later init command).
2106
+
2107
+ **Failure before steps.** If the init command exits non-zero or exceeds its `timeout`, the job **fails before any step runs** -- the init surfaces as a failed `init:<n>` pseudo-step in the run timeline (alongside the step list), its logs are attached, and the step loop never executes. This makes a broken toolchain a clear, early failure rather than a confusing mid-run error.
2108
+
2109
+ **Arrays run in order.** Passing `GenericInitConfig[]` runs the inits sequentially; each one's `$KICI_ENV` / `$KICI_PATH` delta is applied before the next runs, so a later init sees an earlier init's tools on `PATH`. The first init to fail stops the sequence and fails the job.
2110
+
2111
+ **`init: false`** is an explicit opt-out (reserved for a future auto-detect layer); it behaves the same as omitting `init`.
2112
+
1902
2113
  ## Step & job authoring patterns
1903
2114
 
1904
2115
  KiCI supports several authoring patterns for steps and jobs to reduce boilerplate and improve developer experience.
@@ -1997,6 +2208,44 @@ const deploy = job('deploy', {
1997
2208
 
1998
2209
  The `run` function is stored as the job's only step with an auto-generated name (`step-1`). `run` and `steps` are mutually exclusive -- providing both throws an error.
1999
2210
 
2211
+ ### Timeouts
2212
+
2213
+ `timeout` (milliseconds) can be set at three levels. Each level caps **its own scope** independently — a workflow or job timeout is a separate wall-clock cap, **not** a default that flows down to steps.
2214
+
2215
+ | Level | Field | Caps | Enforced by | On breach |
2216
+ | ------------ | ---------------------------- | ------------------------------------------------------ | ---------------- | ----------------------------------------------------------------- |
2217
+ | **step** | `step(..., { timeout })` | A single step's wall-clock. | the agent | The step fails; falls back to the 30-minute default when unset. |
2218
+ | **job** | `job(..., { timeout })` | The job's total wall-clock (init + all steps + hooks). | the agent | The job is aborted and reported failed with a "timed out" reason. |
2219
+ | **workflow** | `workflow(..., { timeout })` | The whole run's wall-clock across all jobs. | the orchestrator | The run is cancelled with a "timed out" reason. |
2220
+
2221
+ ```typescript
2222
+ export default workflow('ci', {
2223
+ timeout: 1_800_000, // whole run must finish within 30 minutes
2224
+ jobs: [
2225
+ job('build', {
2226
+ runsOn: 'linux',
2227
+ timeout: 600_000, // this job (init + steps + hooks) within 10 minutes
2228
+ steps: [
2229
+ step('compile', {
2230
+ timeout: 120_000, // this single step within 2 minutes
2231
+ run: async (ctx) => {
2232
+ await ctx.$`make build`;
2233
+ },
2234
+ }),
2235
+ ],
2236
+ }),
2237
+ ],
2238
+ });
2239
+ ```
2240
+
2241
+ **Precedence — each scope caps its own scope.** The three timeouts are independent caps, not a fallback chain:
2242
+
2243
+ - A **step** with no `timeout` falls back to the 30-minute agent default, regardless of the job or workflow timeout. A job timeout never becomes a step's default.
2244
+ - A **job** `timeout` bounds the job's total wall-clock (its init, every step including their own per-step timeouts, and its hooks). It does not change any step's individual cap.
2245
+ - A **workflow** `timeout` is a run-level deadline. The orchestrator records it when the run starts and cancels the run if its wall-clock exceeds the timeout, even when individual jobs and steps are still within their own caps.
2246
+
2247
+ Workflow and job timeouts surface with a distinct "timed out" reason so the dashboard labels the run or job as timed out rather than a generic failure or cancel.
2248
+
2000
2249
  ### Output chaining
2001
2250
 
2002
2251
  Steps and jobs can access outputs from preceding steps/jobs using two patterns.
@@ -2169,6 +2418,354 @@ These IDs are stable as long as the order of unnamed entries does not change. Ad
2169
2418
 
2170
2419
  ---
2171
2420
 
2421
+ ## Event payload reference
2422
+
2423
+ Source: https://docs.kici.dev/user/sdk/event-payloads/
2424
+
2425
+ <!-- Generated by scripts/docs-gen-event-payloads.ts — do not edit by hand. Regenerate: pnpm docs:gen:events -->
2426
+
2427
+ ## The envelope
2428
+
2429
+ The normalized event envelope is the single event contract in KiCI. Rules receive it as `ctx.event`, and every dynamic function — `environment:`, `env:`, and `concurrencyGroup:` resolvers, generated jobs, and a workflow's `concurrency.group` — receives the same envelope as its argument.
2430
+
2431
+ Narrow on the `type` field to branch per trigger kind (`if (event.type === 'push')`). The raw provider webhook body is nested at `payload`; the typed variants below describe its shape per event type.
2432
+
2433
+ These fields are present on every envelope (the `EventBase` shape):
2434
+
2435
+ | Field | Type | Description |
2436
+ | ----------------- | ------------------------- | ------------------------------------------------------------------------------- |
2437
+ | `type` | `string` | Normalized event type discriminant. |
2438
+ | `action?` | `string` | Sub-action (e.g. 'opened', 'created', 'submitted'). |
2439
+ | `targetBranch?` | `string` | Target branch (push target, PR base, or default branch). |
2440
+ | `sourceBranch?` | `string` | Source branch (PR head branch). Only set for PR-like events. |
2441
+ | `provider?` | `string` | Provider that originated this event. |
2442
+ | `isForkPR?` | `boolean` | Whether this PR comes from a fork. Only set for PR-like events. |
2443
+ | `baseBranch?` | `string` | Base branch ref for PR events. |
2444
+ | `senderUsername?` | `string` | Sender username from the webhook payload. |
2445
+ | `sourceRepo?` | `string` | Repository identifier (e.g. "owner/repo"). |
2446
+ | `changedFiles?` | `string[]` | Files changed in this event (for path filtering). |
2447
+ | `payload?` | `Record<string, unknown>` | Raw webhook payload from the provider. May be absent in flattened event forms. |
2448
+ | `[key: string]` | `unknown` | Index signature for backward compatibility — untyped fields resolve to unknown. |
2449
+
2450
+ ## Event types
2451
+
2452
+ One section per member of the `EventPayload` union. The heading is the `type` literal; the table lists the fields of that event's `payload` property when it declares a typed shape.
2453
+
2454
+ ### `pull_request`
2455
+
2456
+ Carried by `PullRequestEventPayload`. The `payload` property has the following shape:
2457
+
2458
+ | Field | Type | Description |
2459
+ | --------------- | ------------------- | ----------- |
2460
+ | `action` | `string` | |
2461
+ | `number` | `number` | |
2462
+ | `pull_request` | `GitHubPullRequest` | |
2463
+ | `repository` | `GitHubRepository` | |
2464
+ | `sender` | `GitHubUser` | |
2465
+ | `[key: string]` | `unknown` | |
2466
+
2467
+ ### `push`
2468
+
2469
+ Carried by `PushEventPayload`. The `payload` property has the following shape:
2470
+
2471
+ | Field | Type | Description |
2472
+ | --------------- | ------------------ | ----------- |
2473
+ | `ref` | `string` | |
2474
+ | `after` | `string` | |
2475
+ | `before` | `string` | |
2476
+ | `head_commit?` | `GitHubCommit` | |
2477
+ | `commits?` | `GitHubCommit[]` | |
2478
+ | `repository` | `GitHubRepository` | |
2479
+ | `sender?` | `GitHubUser` | |
2480
+ | `forced?` | `boolean` | |
2481
+ | `[key: string]` | `unknown` | |
2482
+
2483
+ ### `tag`
2484
+
2485
+ Carried by `TagEventPayload`. The `payload` property has the following shape:
2486
+
2487
+ | Field | Type | Description |
2488
+ | --------------- | ------------------ | ----------- |
2489
+ | `ref` | `string` | |
2490
+ | `after` | `string` | |
2491
+ | `repository` | `GitHubRepository` | |
2492
+ | `sender?` | `GitHubUser` | |
2493
+ | `[key: string]` | `unknown` | |
2494
+
2495
+ ### `comment`
2496
+
2497
+ Carried by `CommentEventPayload`. The `payload` property has the following shape:
2498
+
2499
+ | Field | Type | Description |
2500
+ | --------------- | ------------------------------------------------------------------------------------ | ----------- |
2501
+ | `action` | `string` | |
2502
+ | `comment` | `GitHubComment` | |
2503
+ | `issue?` | `{ number: number; title?: string; pull_request?: unknown; [key: string]: unknown }` | |
2504
+ | `repository` | `GitHubRepository` | |
2505
+ | `sender` | `GitHubUser` | |
2506
+ | `[key: string]` | `unknown` | |
2507
+
2508
+ ### `review`
2509
+
2510
+ Carried by `ReviewEventPayload`. The `payload` property has the following shape:
2511
+
2512
+ | Field | Type | Description |
2513
+ | --------------- | ------------------- | ----------- |
2514
+ | `action` | `string` | |
2515
+ | `review` | `GitHubReview` | |
2516
+ | `pull_request` | `GitHubPullRequest` | |
2517
+ | `repository` | `GitHubRepository` | |
2518
+ | `sender` | `GitHubUser` | |
2519
+ | `[key: string]` | `unknown` | |
2520
+
2521
+ ### `review_comment`
2522
+
2523
+ Carried by `ReviewCommentEventPayload`. The `payload` property has the following shape:
2524
+
2525
+ | Field | Type | Description |
2526
+ | --------------- | ------------------- | ----------- |
2527
+ | `action` | `string` | |
2528
+ | `comment` | `GitHubComment` | |
2529
+ | `pull_request` | `GitHubPullRequest` | |
2530
+ | `repository` | `GitHubRepository` | |
2531
+ | `sender` | `GitHubUser` | |
2532
+ | `[key: string]` | `unknown` | |
2533
+
2534
+ ### `release`
2535
+
2536
+ Carried by `ReleaseEventPayload`. The `payload` property has the following shape:
2537
+
2538
+ | Field | Type | Description |
2539
+ | --------------- | ------------------ | ----------- |
2540
+ | `action` | `string` | |
2541
+ | `release` | `GitHubRelease` | |
2542
+ | `repository` | `GitHubRepository` | |
2543
+ | `sender` | `GitHubUser` | |
2544
+ | `[key: string]` | `unknown` | |
2545
+
2546
+ ### `dispatch`
2547
+
2548
+ Carried by `DispatchEventPayload`. The `payload` property has the following shape:
2549
+
2550
+ | Field | Type | Description |
2551
+ | ----------------- | ------------------------- | ----------- |
2552
+ | `action` | `string` | |
2553
+ | `client_payload?` | `Record<string, unknown>` | |
2554
+ | `repository` | `GitHubRepository` | |
2555
+ | `sender?` | `GitHubUser` | |
2556
+ | `[key: string]` | `unknown` | |
2557
+
2558
+ ### `create`
2559
+
2560
+ Carried by `CreateEventPayload`. The `payload` property has the following shape:
2561
+
2562
+ | Field | Type | Description |
2563
+ | --------------- | ------------------ | ----------- |
2564
+ | `ref` | `string` | |
2565
+ | `ref_type` | `string` | |
2566
+ | `repository` | `GitHubRepository` | |
2567
+ | `sender` | `GitHubUser` | |
2568
+ | `[key: string]` | `unknown` | |
2569
+
2570
+ ### `delete`
2571
+
2572
+ Carried by `DeleteEventPayload`. The `payload` property has the following shape:
2573
+
2574
+ | Field | Type | Description |
2575
+ | --------------- | ------------------ | ----------- |
2576
+ | `ref` | `string` | |
2577
+ | `ref_type` | `string` | |
2578
+ | `repository` | `GitHubRepository` | |
2579
+ | `sender` | `GitHubUser` | |
2580
+ | `[key: string]` | `unknown` | |
2581
+
2582
+ ### `status`
2583
+
2584
+ Carried by `StatusEventPayload`. The `payload` property has the following shape:
2585
+
2586
+ | Field | Type | Description |
2587
+ | --------------- | ------------------------------------------------- | ----------- |
2588
+ | `state` | `string` | |
2589
+ | `sha` | `string` | |
2590
+ | `context` | `string` | |
2591
+ | `description?` | `string` | |
2592
+ | `target_url?` | `string` | |
2593
+ | `branches?` | `Array<{ name: string; [key: string]: unknown }>` | |
2594
+ | `repository` | `GitHubRepository` | |
2595
+ | `sender` | `GitHubUser` | |
2596
+ | `[key: string]` | `unknown` | |
2597
+
2598
+ ### `workflow_run`
2599
+
2600
+ Carried by `WorkflowRunEventPayload`. The `payload` property has the following shape:
2601
+
2602
+ | Field | Type | Description |
2603
+ | --------------- | ------------------------------------------------------------------------------------------------------ | ----------- |
2604
+ | `action` | `string` | |
2605
+ | `workflow_run` | `{ head_branch: string; name: string; conclusion?: string; status?: string; [key: string]: unknown; }` | |
2606
+ | `repository` | `GitHubRepository` | |
2607
+ | `sender` | `GitHubUser` | |
2608
+ | `[key: string]` | `unknown` | |
2609
+
2610
+ ### `fork`
2611
+
2612
+ Carried by `ForkEventPayload`. The `payload` property has the following shape:
2613
+
2614
+ | Field | Type | Description |
2615
+ | --------------- | ----------------------------------------------- | ----------- |
2616
+ | `forkee` | `{ full_name: string; [key: string]: unknown }` | |
2617
+ | `repository` | `GitHubRepository` | |
2618
+ | `sender` | `GitHubUser` | |
2619
+ | `[key: string]` | `unknown` | |
2620
+
2621
+ ### `star`
2622
+
2623
+ Carried by `StarEventPayload`. The `payload` property has the following shape:
2624
+
2625
+ | Field | Type | Description |
2626
+ | --------------- | ------------------ | ----------- |
2627
+ | `action` | `string` | |
2628
+ | `repository` | `GitHubRepository` | |
2629
+ | `sender` | `GitHubUser` | |
2630
+ | `[key: string]` | `unknown` | |
2631
+
2632
+ ### `watch`
2633
+
2634
+ Carried by `WatchEventPayload`. The `payload` property has the following shape:
2635
+
2636
+ | Field | Type | Description |
2637
+ | --------------- | ------------------ | ----------- |
2638
+ | `action` | `string` | |
2639
+ | `repository` | `GitHubRepository` | |
2640
+ | `sender` | `GitHubUser` | |
2641
+ | `[key: string]` | `unknown` | |
2642
+
2643
+ ### `webhook`
2644
+
2645
+ Carried by `WebhookEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2646
+
2647
+ ### `kici_event`
2648
+
2649
+ Carried by `KiciEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2650
+
2651
+ ### `workflow_complete`
2652
+
2653
+ Carried by `WorkflowCompleteEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2654
+
2655
+ ### `job_complete`
2656
+
2657
+ Carried by `JobCompleteEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2658
+
2659
+ ### `generic_webhook`
2660
+
2661
+ Carried by `GenericWebhookEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2662
+
2663
+ ### `schedule`
2664
+
2665
+ Carried by `ScheduleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2666
+
2667
+ ### `lifecycle`
2668
+
2669
+ Carried by `LifecycleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2670
+
2671
+ ### `rerun`
2672
+
2673
+ Carried by `RerunEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2674
+
2675
+ ### `manual_schedule`
2676
+
2677
+ Carried by `ManualScheduleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2678
+
2679
+ ### `unknown`
2680
+
2681
+ Carried by `UnknownEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
2682
+
2683
+ ## Shared GitHub object shapes
2684
+
2685
+ The typed `payload` shapes above reference these partial GitHub object types. Each lists only the commonly accessed fields; the index signature on every shape resolves any other field to `unknown`.
2686
+
2687
+ ### `GitHubRepository`
2688
+
2689
+ | Field | Type | Description |
2690
+ | ---------------- | ------------------------------------------- | ----------- |
2691
+ | `full_name` | `string` | |
2692
+ | `default_branch` | `string` | |
2693
+ | `name?` | `string` | |
2694
+ | `owner?` | `{ login: string; [key: string]: unknown }` | |
2695
+ | `private?` | `boolean` | |
2696
+ | `[key: string]` | `unknown` | |
2697
+
2698
+ ### `GitHubUser`
2699
+
2700
+ | Field | Type | Description |
2701
+ | --------------- | --------- | ----------- |
2702
+ | `login` | `string` | |
2703
+ | `id?` | `number` | |
2704
+ | `[key: string]` | `unknown` | |
2705
+
2706
+ ### `GitHubPullRequest`
2707
+
2708
+ | Field | Type | Description |
2709
+ | --------------- | ------------------------------------------------------------------------------------------------------------- | ----------- |
2710
+ | `number` | `number` | |
2711
+ | `draft?` | `boolean` | |
2712
+ | `title?` | `string` | |
2713
+ | `body?` | `string` | |
2714
+ | `state?` | `string` | |
2715
+ | `merged?` | `boolean` | |
2716
+ | `head` | `{ ref: string; sha: string; repo?: { full_name: string; [key: string]: unknown }; [key: string]: unknown; }` | |
2717
+ | `base` | `{ ref: string; repo?: { full_name: string; [key: string]: unknown }; [key: string]: unknown; }` | |
2718
+ | `user?` | `GitHubUser` | |
2719
+ | `labels?` | `Array<{ name: string; [key: string]: unknown }>` | |
2720
+ | `[key: string]` | `unknown` | |
2721
+
2722
+ ### `GitHubCommit`
2723
+
2724
+ | Field | Type | Description |
2725
+ | --------------- | ------------------------------------------------------------------------------ | ----------- |
2726
+ | `id` | `string` | |
2727
+ | `message` | `string` | |
2728
+ | `author?` | `{ name?: string; email?: string; username?: string; [key: string]: unknown }` | |
2729
+ | `timestamp?` | `string` | |
2730
+ | `added?` | `string[]` | |
2731
+ | `removed?` | `string[]` | |
2732
+ | `modified?` | `string[]` | |
2733
+ | `[key: string]` | `unknown` | |
2734
+
2735
+ ### `GitHubComment`
2736
+
2737
+ | Field | Type | Description |
2738
+ | --------------- | ------------ | ----------- |
2739
+ | `id` | `number` | |
2740
+ | `body` | `string` | |
2741
+ | `user` | `GitHubUser` | |
2742
+ | `[key: string]` | `unknown` | |
2743
+
2744
+ ### `GitHubReview`
2745
+
2746
+ | Field | Type | Description |
2747
+ | --------------- | ------------ | ----------- |
2748
+ | `id` | `number` | |
2749
+ | `state` | `string` | |
2750
+ | `body?` | `string` | |
2751
+ | `user` | `GitHubUser` | |
2752
+ | `[key: string]` | `unknown` | |
2753
+
2754
+ ### `GitHubRelease`
2755
+
2756
+ | Field | Type | Description |
2757
+ | ------------------- | --------- | ----------- |
2758
+ | `id` | `number` | |
2759
+ | `tag_name` | `string` | |
2760
+ | `name?` | `string` | |
2761
+ | `body?` | `string` | |
2762
+ | `draft?` | `boolean` | |
2763
+ | `prerelease?` | `boolean` | |
2764
+ | `target_commitish?` | `string` | |
2765
+ | `[key: string]` | `unknown` | |
2766
+
2767
+ ---
2768
+
2172
2769
  ## SDK reference: idempotent
2173
2770
 
2174
2771
  Source: https://docs.kici.dev/user/sdk/idempotent/
@@ -2442,17 +3039,7 @@ You can also narrow directly with `if (ctx.event.type === 'pull_request')` — T
2442
3039
 
2443
3040
  `EventPayload` is a discriminated union over the `type` field. Each variant provides typed access to the normalized event fields and the raw webhook payload.
2444
3041
 
2445
- **Common fields** (on all variants via `EventBase`):
2446
-
2447
- | Field | Type | Description |
2448
- | ---------------- | ------------------------------ | --------------------------------------- |
2449
- | `type` | `string` (literal per variant) | Event type discriminant |
2450
- | `action` | `string \| undefined` | Sub-action (e.g. 'opened', 'created') |
2451
- | `targetBranch` | `string \| undefined` | Target branch (push target, PR base) |
2452
- | `sourceBranch` | `string \| undefined` | Source branch (PR head) |
2453
- | `provider` | `string \| undefined` | Provider name (e.g. 'github') |
2454
- | `senderUsername` | `string \| undefined` | Webhook sender username |
2455
- | `payload` | `Record<string, unknown>` | Raw webhook payload (typed per variant) |
3042
+ Every variant carries the shared `EventBase` fields `type`, `action`, `targetBranch`, `sourceBranch`, `provider`, `isForkPR`, `baseBranch`, `senderUsername`, `sourceRepo`, `changedFiles`, and the raw `payload` — plus a per-type `payload` shape for the typed variants. The complete field-by-field schema, including every typed `payload` shape and the shared GitHub object types, is in the [event payload reference](./event-payloads.md).
2456
3043
 
2457
3044
  **Typed variants** (with GitHub-specific payload fields): `pull_request`, `push`, `tag`, `comment`, `review`, `review_comment`, `release`, `dispatch`, `create`, `delete`, `status`, `workflow_run`, `fork`, `star`, `watch`.
2458
3045
 
@@ -2480,16 +3067,16 @@ step('test', async ({ $, matrix }) => {
2480
3067
 
2481
3068
  ```typescript
2482
3069
  matrix: {
2483
- os: ['ubuntu-latest', 'macos-latest'],
3070
+ os: ['linux', 'arm64'],
2484
3071
  node: ['18', '20'],
2485
3072
  }
2486
3073
  ```
2487
3074
 
2488
- Creates 4 job instances (2 x 2). In steps, values are named properties:
3075
+ Creates 4 job instances (2 x 2). The `os` values (`linux`, `arm64`) are **customer-defined scaler labels** matched by subset semantics against the labels your orchestrator advertises in its scaler `labelSets` — not hosted-runner names. In steps, values are named properties:
2489
3076
 
2490
3077
  ```typescript
2491
3078
  step('test', async ({ $, matrix }) => {
2492
- console.log(matrix!.os); // 'ubuntu-latest' or 'macos-latest'
3079
+ console.log(matrix!.os); // 'linux' or 'arm64'
2493
3080
  console.log(matrix!.node); // '18' or '20'
2494
3081
  });
2495
3082
  ```
@@ -2522,14 +3109,14 @@ Fine-tune matrix combinations on multi-dimensional matrices:
2522
3109
 
2523
3110
  ```typescript
2524
3111
  matrix: {
2525
- os: ['ubuntu-latest', 'macos-latest', 'windows-latest'],
3112
+ os: ['linux', 'arm64', 'windows'],
2526
3113
  node: ['18', '20', '22'],
2527
3114
  },
2528
3115
  exclude: [
2529
- { os: 'windows-latest', node: '18' },
3116
+ { os: 'windows', node: '18' },
2530
3117
  ],
2531
3118
  include: [
2532
- { os: 'ubuntu-latest', node: '23' },
3119
+ { os: 'linux', node: '23' },
2533
3120
  ],
2534
3121
  ```
2535
3122
 
@@ -2599,7 +3186,7 @@ const discoverJobs: DynamicJobFn = async ({ $ }) => {
2599
3186
  const packages = result.stdout.trim().split('\n');
2600
3187
  return packages.map((pkg) =>
2601
3188
  job(`test-${pkg}`, {
2602
- runsOn: 'ubuntu-latest',
3189
+ runsOn: 'linux',
2603
3190
  steps: [
2604
3191
  step('test', async ({ $ }) => {
2605
3192
  await $`cd packages/${pkg} && pnpm test`;
@@ -2858,7 +3445,7 @@ Use whichever style is convenient — you don't have to wrap `console.log` in th
2858
3445
 
2859
3446
  ### setEnv(key, value)
2860
3447
 
2861
- Set an environment variable that is visible to the current step and all subsequent steps in the same job. This is the equivalent of `echo "KEY=VALUE" >> $GITHUB_ENV` in GitHub Actions.
3448
+ Export an environment variable to later steps in the same job. This is the canonical way to hand a value computed in one step to the steps that follow the equivalent of `echo "KEY=VALUE" >> $GITHUB_ENV` in GitHub Actions. The value is visible to the current step and all subsequent steps in the job.
2862
3449
 
2863
3450
  ```typescript
2864
3451
  step('setup', async (ctx) => {
@@ -2879,6 +3466,7 @@ step('use', async (ctx) => {
2879
3466
  - Last-write-wins -- if multiple steps set the same key, the last value is used
2880
3467
  - Cannot override operator-injected secrets (the operator value takes precedence)
2881
3468
  - Changes take effect immediately in the current step and persist for all subsequent steps
3469
+ - Shell commands export the same way by appending to `$KICI_ENV` (see [Exporting env from shell commands](#exporting-env-from-shell-commands-kici_env--kici_path) below)
2882
3470
 
2883
3471
  ### addPath(dir)
2884
3472
 
@@ -2896,6 +3484,40 @@ step('build', async (ctx) => {
2896
3484
  });
2897
3485
  ```
2898
3486
 
3487
+ ### Exporting env from shell commands ($KICI_ENV / $KICI_PATH)
3488
+
3489
+ `setEnv` and `addPath` are the TypeScript form of "export env to later steps". A shell command — including a non-JS toolchain installer — exports env the same way by appending to two files the agent points at before every step:
3490
+
3491
+ - **`$KICI_ENV`** — append `KEY=value` lines. Each becomes an environment variable visible to subsequent steps, exactly like `ctx.setEnv('KEY', 'value')`.
3492
+ - **`$KICI_PATH`** — append one directory per line. Each is prepended to `PATH` for subsequent steps, exactly like `ctx.addPath(dir)`. The first directory appended ends up first on `PATH`.
3493
+
3494
+ ```typescript
3495
+ step('install-tool', async (ctx) => {
3496
+ await ctx.$`./install-mytool.sh`; // installs to /opt/mytool
3497
+ // Export from the shell, no JS round-trip needed:
3498
+ await ctx.$`echo "MYTOOL_HOME=/opt/mytool" >> "$KICI_ENV"`;
3499
+ await ctx.$`echo "/opt/mytool/bin" >> "$KICI_PATH"`;
3500
+ });
3501
+
3502
+ step('build', async (ctx) => {
3503
+ // MYTOOL_HOME is set and /opt/mytool/bin is on PATH here.
3504
+ await ctx.$`mytool build`;
3505
+ });
3506
+ ```
3507
+
3508
+ **Format (v1):**
3509
+
3510
+ - One `KEY=value` per line in `$KICI_ENV`. The split is on the first `=`, so the value may contain `=`. Blank lines and lines without a `=` are ignored.
3511
+ - One directory per line in `$KICI_PATH`. Blank lines are ignored.
3512
+ - Values must be single-line — embedded newlines are not supported in v1.
3513
+
3514
+ **Behavior (shared with `setEnv` / `addPath`):**
3515
+
3516
+ - Applied after the step completes and visible to every later step in the job.
3517
+ - Last-write-wins on a repeated key.
3518
+ - Cannot override an operator-injected secret — a collision is ignored and logged, and the operator value is preserved.
3519
+ - The files are reset before each step, so each step sees only its own appended lines.
3520
+
2899
3521
  ### setSecretOutput(key, value)
2900
3522
 
2901
3523
  Publish an encrypted secret output from this job. Downstream jobs that list this job in their `needs` array receive the value merged into `ctx.secrets`.
@@ -2944,7 +3566,7 @@ Each job picks its secret environment via the `environment` option on `job()`. T
2944
3566
 
2945
3567
  ```typescript
2946
3568
  const deploy = job('deploy', {
2947
- runsOn: 'ubuntu-latest',
3569
+ runsOn: 'linux',
2948
3570
  environment: 'production',
2949
3571
  steps: [
2950
3572
  /* ... */
@@ -2983,7 +3605,7 @@ step('deploy', async ({ secrets }) => {
2983
3605
  import { workflow, job, step, push } from '@kici-dev/sdk';
2984
3606
 
2985
3607
  const deploy = job('deploy', {
2986
- runsOn: 'ubuntu-latest',
3608
+ runsOn: 'linux',
2987
3609
  environment: 'production',
2988
3610
  steps: [
2989
3611
  step('deploy', async (ctx) => {
@@ -3899,6 +4521,7 @@ Reference documentation for `@kici-dev/sdk`. The reference is split across five
3899
4521
  | [Core](./sdk/core.md) | `workflow()`, `job()`, `step()` factory functions and step / job authoring patterns (bare functions, output chaining, `needs`, dynamic groups). |
3900
4522
  | [Triggers](./sdk/triggers.md) | All 22 trigger factories -- GitHub events (`pr`, `push`, `tag`, `comment`, ...), event triggers (`kiciEvent`, `workflowComplete`, `jobComplete`), `genericWebhook`, `schedule`, `lifecycle`, plus branch-pattern semantics. |
3901
4523
  | [Rules, matrix, dynamic jobs](./sdk/rules-matrix-dynamic.md) | `rule()`, `skip()`, matrix builds (static + dynamic), and `dynamicJob()` / `dynamicGroup()`. |
4524
+ | [Caching](./sdk/caching.md) | `CacheSpec`, declarative `cache` on jobs/steps, imperative `ctx.cache.restore()` / `ctx.cache.save()`, immutable keys, `restoreKeys` prefix fallback, per-org + per-ref isolation. |
3902
4525
  | [Validation & events](./sdk/validation-events.md) | `validateDag()`, `defineEvent()`, event emission patterns. |
3903
4526
  | [Runtime](./sdk/runtime.md) | Types index, `StepContext`, secrets, and fixtures. |
3904
4527
  | [Idempotent helpers](./sdk/idempotent.md) | `idempotent()` and `idempotentStep()` — check / apply pattern with typed results on both the skipped and applied branches. |
@@ -4089,7 +4712,7 @@ JWT and opaque OIDC tokens are validated against the configured OIDC issuer (JWK
4089
4712
 
4090
4713
  ### Permissions
4091
4714
 
4092
- Tokens authenticate; RBAC authorizes. Every org-scoped route runs `orgContextMiddleware` (verifies you are a member of the target org) followed by `requirePermission(resource, level)`. The 14 resources and 5 levels are documented in [RBAC](../architecture/security/rbac.md#permission-model). User API keys carry their own permission matrix bounded above by the creator's effective permissions; PATs inherit the user's role permissions (or are capped further by their `scopes` field).
4715
+ Tokens authenticate; RBAC authorizes. Every org-scoped route runs `orgContextMiddleware` (verifies you are a member of the target org) followed by `requirePermission(resource, level)`. The 15 resources and 5 levels are documented in [RBAC](../architecture/security/rbac.md#permission-model). User API keys carry their own permission matrix bounded above by the creator's effective permissions; PATs inherit the user's role permissions (or are capped further by their `scopes` field).
4093
4716
 
4094
4717
  ### Configurable surfaces
4095
4718
 
@@ -4346,6 +4969,7 @@ Cleanup policy:
4346
4969
  - On a fully successful run, the isolated checkout is removed.
4347
4970
  - On failure, it is retained and its path is logged so you can inspect the failed state.
4348
4971
  - `--keep` always retains it, even on success.
4972
+ - Retained checkouts are garbage-collected after 72 hours by the next `kici run local` invocation — copy a checkout elsewhere if you need it longer.
4349
4973
 
4350
4974
  Set the `KICI_RUN_DIR` environment variable to place the isolated checkout under a base directory other than the system temp directory.
4351
4975
 
@@ -4402,6 +5026,8 @@ kici run local push --keep-going
4402
5026
 
4403
5027
  Execute fixtures remotely through the full CI pipeline. Fixtures are defined in `.kici/tests/*.ts` using the `fixture()` factory function. Without arguments, lists available fixtures.
4404
5028
 
5029
+ Requires `kici login` (an authenticated session) and a target orchestrator that has **cache storage configured** (`KICI_STORAGE_TYPE` = `s3` or `filesystem`) — the command uploads your working-tree overlay to that storage for the agent to fetch. The quickstart orchestrators do not enable storage by default; see the [testing guide](testing-guide.md) for setup (including non-public / self-hosted S3 endpoints).
5030
+
4405
5031
  ```bash
4406
5032
  kici run remote [fixture] [options]
4407
5033
  ```
@@ -4414,21 +5040,21 @@ kici run remote [fixture] [options]
4414
5040
 
4415
5041
  **Options:**
4416
5042
 
4417
- | Option | Default | Description |
4418
- | --------------------------- | ------- | -------------------------------------------------- |
4419
- | `--all` | `false` | Run all fixtures |
4420
- | `--workflow <name>` | none | Run a specific workflow directly (bypass triggers) |
4421
- | `--parallel` | `false` | Run multiple fixtures concurrently |
4422
- | `--no-wait` | - | Fire and forget (print runIds, don't stream) |
4423
- | `--quiet` | `false` | Minimal output (only final result) |
4424
- | `--json` | `false` | Machine-readable JSON output |
4425
- | `--junit <path>` | none | JUnit XML output to file for CI integration |
4426
- | `--history` | `false` | Show table of recent test runs |
4427
- | `--routing-key <key>` | none | Override routing key for this run |
4428
- | `--secret <key=value>` | none | Inject flat secret (repeatable) |
4429
- | `--context <ctx.key=value>` | none | Inject context secret (repeatable) |
4430
- | `--debug` | `false` | Verbose internals |
4431
- | `--kici-dir <path>` | `.kici` | Path to .kici directory |
5043
+ | Option | Default | Description |
5044
+ | --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
5045
+ | `--all` | `false` | Run all fixtures |
5046
+ | `--workflow <name>` | none | Run a specific workflow directly (bypass triggers) |
5047
+ | `--parallel` | `false` | Run multiple fixtures concurrently |
5048
+ | `--no-wait` | - | Fire and forget (print runIds, don't stream) |
5049
+ | `--quiet` | `false` | Minimal output (only final result) |
5050
+ | `--json` | `false` | Machine-readable JSON output |
5051
+ | `--junit <path>` | none | JUnit XML output to file for CI integration |
5052
+ | `--history` | `false` | Show table of recent test runs |
5053
+ | `--routing-key <key>` | none | Override routing key for this run |
5054
+ | `--context <ctx.key=value>` | none | Inject a namespaced context secret, uploaded encrypted to the orchestrator (repeatable) |
5055
+ | `--env <KEY=VALUE>` | none | Provide a per-run secret, uploaded encrypted to the orchestrator (repeatable) — see [testing guide](testing-guide.md) |
5056
+ | `--debug` | `false` | Verbose internals |
5057
+ | `--kici-dir <path>` | `.kici` | Path to .kici directory |
4432
5058
 
4433
5059
  **Examples:**
4434
5060
 
@@ -4561,14 +5187,14 @@ kici login [options]
4561
5187
 
4562
5188
  **Environment variables:**
4563
5189
 
4564
- | Variable | Default | Description |
4565
- | --------------------- | ------------------------- | ---------------------------------------------------------------------- |
4566
- | `KICI_PLATFORM_URL` | none (required for OAuth) | Platform API base URL |
4567
- | `KICI_OIDC_ISSUER` | none (required for OAuth) | OIDC issuer URL |
4568
- | `KICI_OIDC_CLIENT_ID` | none (required for OAuth) | OIDC client ID |
4569
- | `KICI_BROWSER_CMD` | uses `open` package | Custom browser command with `{url}` placeholder, or `none` to suppress |
4570
- | `KICI_CALLBACK_PORT` | random | Fixed port for OAuth PKCE callback server |
4571
- | `KICI_CONFIG_DIR` | `~/.kici` | Override config directory |
5190
+ | Variable | Default | Description |
5191
+ | --------------------- | -------------------------------------------- | ---------------------------------------------------------------------- |
5192
+ | `KICI_PLATFORM_URL` | `https://api.kici.dev` | Platform API base URL (override for a self-hosted Platform) |
5193
+ | `KICI_OIDC_ISSUER` | `https://auth.kici.dev/realms/kici-internal` | OIDC issuer URL (override for a self-hosted Platform) |
5194
+ | `KICI_OIDC_CLIENT_ID` | `kici-cli` | OIDC client ID (override for a self-hosted Platform) |
5195
+ | `KICI_BROWSER_CMD` | uses `open` package | Custom browser command with `{url}` placeholder, or `none` to suppress |
5196
+ | `KICI_CALLBACK_PORT` | random | Fixed port for OAuth PKCE callback server |
5197
+ | `KICI_CONFIG_DIR` | `~/.kici` | Override config directory |
4572
5198
 
4573
5199
  **Examples:**
4574
5200
 
@@ -4751,6 +5377,72 @@ kici cancel abc123 --force
4751
5377
  kici cancel --branch feature/wip
4752
5378
  ```
4753
5379
 
5380
+ ### kici approve
5381
+
5382
+ Approve a held [approval gate](approvals.md) so the run resumes. Identify the held element by run ID, optionally narrowed to a job and step.
5383
+
5384
+ ```bash
5385
+ kici approve <run-id> [options]
5386
+ ```
5387
+
5388
+ **Arguments:**
5389
+
5390
+ | Argument | Required | Description |
5391
+ | -------- | -------- | ---------------------------------- |
5392
+ | `run-id` | yes | Run ID holding the gate to approve |
5393
+
5394
+ **Options:**
5395
+
5396
+ | Option | Default | Description |
5397
+ | ----------------- | ------- | --------------------------------------------------- |
5398
+ | `--job <name>` | none | Approve a held job (omit for a workflow-level hold) |
5399
+ | `--step <name>` | none | Approve a held step (used with `--job`) |
5400
+ | `--reason <text>` | none | Optional note recorded with the approval |
5401
+
5402
+ **Examples:**
5403
+
5404
+ ```bash
5405
+ # Approve a workflow-level hold
5406
+ kici approve abc123
5407
+
5408
+ # Approve a held job
5409
+ kici approve abc123 --job deploy-production
5410
+
5411
+ # Approve a held step
5412
+ kici approve abc123 --job migrate-and-deploy --step apply-migration
5413
+ ```
5414
+
5415
+ You must be eligible for at least one unsatisfied clause (a member of a named team, or a named user) and hold the `environments:write` or `ci_trust:write` permission. The command reports whether the element was released, how many clauses remain, or that it was rejected.
5416
+
5417
+ ### kici reject
5418
+
5419
+ Reject a held [approval gate](approvals.md). A rejection fails the held element and the run. A reason is required.
5420
+
5421
+ ```bash
5422
+ kici reject <run-id> --reason <text> [options]
5423
+ ```
5424
+
5425
+ **Arguments:**
5426
+
5427
+ | Argument | Required | Description |
5428
+ | -------- | -------- | --------------------------------- |
5429
+ | `run-id` | yes | Run ID holding the gate to reject |
5430
+
5431
+ **Options:**
5432
+
5433
+ | Option | Default | Description |
5434
+ | ----------------- | ------- | -------------------------------------------------- |
5435
+ | `--reason <text>` | none | Required. Reason recorded with the rejection |
5436
+ | `--job <name>` | none | Reject a held job (omit for a workflow-level hold) |
5437
+ | `--step <name>` | none | Reject a held step (used with `--job`) |
5438
+
5439
+ **Examples:**
5440
+
5441
+ ```bash
5442
+ # Reject a held job with a reason
5443
+ kici reject abc123 --job deploy-production --reason "Wrong release branch"
5444
+ ```
5445
+
4754
5446
  ### kici secrets list
4755
5447
 
4756
5448
  List secret contexts available for test runs. Shows context names and key names (not values).
@@ -5416,13 +6108,13 @@ If you change a workflow file (`.ts`) but do **not** regenerate and commit the l
5416
6108
 
5417
6109
  The lock file (`kici.lock.json`) is a JSON file with the following top-level fields:
5418
6110
 
5419
- | Field | Description |
5420
- | --------------- | ------------------------------------------------------------------------------------------------------------------- |
5421
- | `schemaVersion` | Lock file schema version (currently 12). Incremented on breaking format changes. |
5422
- | `source` | Reference to the source file and export (e.g., `{ file: “.kici/workflows/ci.ts”, export: “#default” }`). |
5423
- | `contentHash` | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. |
5424
- | `lockfileHash` | SHA-256 of `.kici/package-lock.json`, used as the dependency cache key. Omitted when no package lock file exists. |
5425
- | `workflows` | Array of workflow entries, each with its own `contentHash`, `compileSchemaVersion`, triggers, and jobs. |
6111
+ | Field | Description |
6112
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6113
+ | `schemaVersion` | Lock file schema version (currently 15). Incremented on breaking format changes. |
6114
+ | `source` | Reference to the source file and export (e.g., `{ file: “.kici/workflows/ci.ts”, export: “#default” }`). |
6115
+ | `contentHash` | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. |
6116
+ | `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. |
6117
+ | `workflows` | Array of workflow entries, each with its own `contentHash`, `compileSchemaVersion`, triggers, and jobs. |
5426
6118
 
5427
6119
  Each workflow entry includes:
5428
6120
 
@@ -5438,7 +6130,11 @@ Each workflow entry includes:
5438
6130
  | `description` | Optional workflow description. |
5439
6131
  | `hashFiles` | Declared glob patterns for extra files included in the content hash (optional). See [extra files in the content hash](#extra-files-in-the-content-hash-hashfiles). |
5440
6132
  | `resolvedHashFiles` | Resolved file paths from `hashFiles` at compile time (optional). Recorded so the agent can verify without re-discovering. |
6133
+ | `contexts` | Secret contexts declared by the workflow (optional). The orchestrator validates access to each context before dispatch. |
6134
+ | `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](private-registries.md). |
6135
+ | `installEnv` | Extra qualified secret refs (`<environment>:<secret-name>`) projected as env vars on the install subprocess for use with a committed `.kici/.npmrc` (optional). See [private registries](private-registries.md). |
5441
6136
  | `concurrency` | Workflow-level concurrency config: `hasGroup`, `cancelInProgress`, `max` (optional). See [concurrency groups](concurrency.md). |
6137
+ | `timeout` | Whole-run wall-clock timeout in milliseconds (optional). The orchestrator reads this at run creation to set the run deadline. |
5442
6138
  | Hook flags | Boolean flags (`hasOnCancel`, `hasCleanup`, `hasOnSuccess`, `hasOnFailure`) indicating which lifecycle hooks are defined. Job entries additionally have `hasBeforeStep` and `hasAfterStep`. |
5443
6139
 
5444
6140
  ## Rule: commit both together
@@ -5534,11 +6230,22 @@ Test your workflows remotely against the full CI pipeline from your local machin
5534
6230
 
5535
6231
  - Run any workflow against your current working tree (including unstaged changes)
5536
6232
  - Get real-time log output streamed back to your terminal
5537
- - Use test-specific secret contexts that are isolated from production
6233
+ - Give test runs test-scoped secrets — your local secret files and `--env` values (uploaded encrypted) plus any environment flagged `allowLocalExecution: true` — while production environments stay unreachable
5538
6234
  - Detect test mode in workflow code via `ctx.isTestRun`
5539
6235
 
5540
6236
  The command is remote-only -- all execution happens on the orchestrator and agent. For local-only trigger matching previews, use `kici test <event>`.
5541
6237
 
6238
+ :::note[Orchestrator prerequisite: cache storage]
6239
+ `kici run remote` uploads your working-tree overlay to the orchestrator's **cache storage** via a pre-signed URL, and the agent fetches it from there (see [Repo state transfer](#repo-state-transfer)). The target orchestrator must therefore have cache storage enabled (`KICI_STORAGE_TYPE` = `s3` or `filesystem`).
6240
+
6241
+ - **The [Docker / Podman quickstart](quickstart/compose.md) wires this up for you** — it ships a SeaweedFS service, so `kici run remote` works there out of the box (see its "run a workflow without pushing" step).
6242
+ - **The [bare-metal quickstart](quickstart/bare-metal.md) does not configure storage by default** — enable a backend before using `kici run remote`:
6243
+ - **`filesystem`** — simplest for a single-host orchestrator: set `KICI_STORAGE_TYPE=filesystem` and `KICI_STORAGE_FS_PATH=/var/lib/kici/cache`. No external service needed; blobs are served through the orchestrator's own HMAC-signed HTTP route.
6244
+ - **`s3`** — any S3-compatible bucket. **A non-public / self-hosted endpoint works**: set `KICI_STORAGE_TYPE=s3`, `KICI_STORAGE_BUCKET`, `KICI_STORAGE_ENDPOINT=https://your-endpoint` and (for most self-hosted services) `KICI_STORAGE_FORCE_PATH_STYLE=true`. If the developer machine running `kici run remote` reaches the bucket at a different address than the orchestrator, set `KICI_STORAGE_UPLOAD_ENDPOINT` to the developer-reachable address; if agents reach it at yet another address (e.g. agents in containers), set `KICI_STORAGE_EXTERNAL_ENDPOINT` to the agent-routable URL.
6245
+
6246
+ See [Storage layout](../operator/orchestrator/storage-layout.md) for the full env-var reference.
6247
+ :::
6248
+
5542
6249
  ## Getting started
5543
6250
 
5544
6251
  ### 1. Authenticate
@@ -5637,7 +6344,7 @@ When not specified, these default to values detected from your local git repo (c
5637
6344
 
5638
6345
  ### Secret context mappings
5639
6346
 
5640
- Map test secret contexts to your fixture:
6347
+ Map secret contexts to your fixture:
5641
6348
 
5642
6349
  ```typescript
5643
6350
  export const pushWithSecrets = fixture('push-with-secrets', {
@@ -5649,7 +6356,12 @@ export const pushWithSecrets = fixture('push-with-secrets', {
5649
6356
  });
5650
6357
  ```
5651
6358
 
5652
- This maps the `db` secret context to the `test-database` context on the orchestrator, and `api` to `test-api-keys`. Only environments with `allowLocalExecution: true` are accessible. See [Secret contexts for testing](#secret-contexts-for-testing) below.
6359
+ This maps the `db` secret context to the `test-database` context, and `api` to `test-api-keys`.
6360
+
6361
+ This mapping is honored by **both** `kici run local` and `kici run remote`:
6362
+
6363
+ - For **`kici run local`** (see [`kici run local`](cli-reference.md#kici-run-local)), each named context is resolved from your local secret files (`.kici/.secrets`, `.env.local`, `secrets.yaml`, and `--env` flags).
6364
+ - For **`kici run remote`**, each named context maps to an orchestrator **environment**, and the orchestrator resolves that environment's secrets for the run. The target environment must be flagged `allowLocalExecution: true` — mapping a context to a missing or non-test environment rejects the run (see [Secret contexts for testing](#secret-contexts-for-testing) below).
5653
6365
 
5654
6366
  ### Async fixtures
5655
6367
 
@@ -5780,39 +6492,76 @@ The CLI always shows a pre-upload summary before transferring:
5780
6492
 
5781
6493
  ## Secret contexts for testing
5782
6494
 
5783
- Test runs can only access secret contexts that have been explicitly marked as test-accessible.
6495
+ The goal of the test-secret model is to let test runs reach **test-only credentials** while keeping production credentials out of reach. `kici run remote` combines two sources of secrets for a test run, then merges them with a clear precedence and a fail-closed gate.
6496
+
6497
+ ### CLI-uploaded local secrets
6498
+
6499
+ `kici run remote` collects the same local secret values that `kici run local` reads — `.kici/.secrets`, `.kici/.env.local`, `.kici/secrets.yaml`, and any `--env KEY=VALUE` flags — and uploads them **encrypted** to the orchestrator alongside the run. The orchestrator decrypts them only to inject them into the agent for that run; the control plane never sees the values.
6500
+
6501
+ ```bash
6502
+ # Provide an ad-hoc test value for a single remote run
6503
+ kici run remote push-main --env KICI_DATABASE_URL=postgresql://localhost/test
6504
+ ```
6505
+
6506
+ `--env` provides a **flat** per-run override; `--context <ctx>.<KEY>=<value>` is its sibling for a **namespaced** per-run override, placing the value under the named context `ctx`. Both are uploaded **encrypted** and follow the same precedence rule below — a CLI-supplied value wins over the orchestrator test-environment secret on a key collision.
5784
6507
 
5785
- ### Operator configuration
6508
+ ```bash
6509
+ # Provide a namespaced per-run value under the 'db' context
6510
+ kici run remote push-db --context db.KICI_DATABASE_URL=postgresql://localhost/test
6511
+ ```
5786
6512
 
5787
- Test-run access to a secret context is gated by a single flag on the environment that owns the bound secrets:
6513
+ Because these values originate on your machine, they are the natural place to put throwaway test credentials without touching any orchestrator-stored secret.
5788
6514
 
5789
- - **`allowLocalExecution`** -- When `true`, CLI-initiated test runs (`kici run remote`) may resolve secrets through this environment. Production environments should leave this `false` so test runs cannot reach production credentials.
6515
+ ### Orchestrator test-environment secrets
5790
6516
 
5791
- ### Fixture secret mappings
6517
+ In addition to your uploaded values, the orchestrator resolves test-scoped secrets from its own store for a remote test run:
5792
6518
 
5793
- In your fixture, map local names to remote context names:
6519
+ - The job's own declared `environment` contributes its resolved secrets (flat). Static strings and **pure dynamic functions** both participate: a pure `environment:` function (see [Dynamic values](dynamic-values.md)) is evaluated against the fixture's simulated event, and the resolved name is gated and resolved like a static one. Impure dynamic functions (those requiring an init job) are not evaluated for test runs — use a fixture `secrets:` mapping (or `--context`) to supply such a job's secrets.
6520
+ - Each fixture `secrets: { ctx: envName }` mapping resolves the named environment's secrets under the namespaced context `ctx`.
6521
+
6522
+ Both paths are restricted to environments flagged `allowLocalExecution: true`. A production environment left at the default `false` is never resolvable for a test run.
5794
6523
 
5795
6524
  ```typescript
5796
6525
  export const pushWithDb = fixture('push-db', {
5797
6526
  event: push({ branches: ['main'] }),
5798
- secrets: { db: 'test-database' },
6527
+ secrets: { db: 'test-database' }, // 'test-database' must be allowLocalExecution: true
5799
6528
  });
5800
6529
  ```
5801
6530
 
5802
- In your workflow, access secrets normally via `ctx.secrets` or `ctx.contexts`:
5803
-
5804
6531
  ```typescript
5805
6532
  step('migrate', async (ctx) => {
5806
- const dbUrl = ctx.secrets.KICI_DATABASE_URL;
6533
+ const dbUrl = await ctx.secrets.get('KICI_DATABASE_URL');
5807
6534
  await ctx.$`npx prisma migrate deploy`;
5808
6535
  });
5809
6536
  ```
5810
6537
 
5811
- During a test run, the `KICI_DATABASE_URL` secret is resolved from the `test-database` context instead of the production context.
6538
+ ### Precedence: CLI values win
6539
+
6540
+ When a key exists in both sources, the **CLI-uploaded local value wins** over the orchestrator test-environment value. This makes a local override a per-run knob: set `--env KICI_DATABASE_URL=...` (or put it in `.kici/.secrets`) to shadow the test environment's value for just that run, without changing anything on the orchestrator.
6541
+
6542
+ ### Fail-closed on non-test environments
6543
+
6544
+ Test-run secret resolution is fail-closed:
6545
+
6546
+ - If a fixture maps a context to an environment that does not exist, the run is **rejected**.
6547
+ - If a fixture maps a context to an environment whose `allowLocalExecution` is `false`, the run is **rejected**.
6548
+ - The `allowLocalExecution` gate applies to **all** remote test runs: a run whose matched workflow targets an environment with the flag off is rejected, so a test run can never resolve production secrets.
6549
+
6550
+ ### The `allowLocalExecution` environment flag
6551
+
6552
+ Each environment carries an `allowLocalExecution` flag (default `false`) that controls test-run access to that environment and to its secrets. Production environments should leave it at `false`; create a dedicated test environment with `allowLocalExecution: true` that binds only test-only secret scopes for the contexts you want test runs to use.
6553
+
6554
+ The flag is set by the orchestrator operator, either via the CLI:
6555
+
6556
+ ```bash
6557
+ kici-admin environment set-policy --env test-database --allow-local-execution true
6558
+ ```
6559
+
6560
+ or via the dashboard's "Test runs" toggle on the environment detail page. `kici secrets list` only surfaces contexts whose owning environment has `allowLocalExecution: true`, so production environments are never advertised as test-accessible.
5812
6561
 
5813
- ### Fail-closed behavior
6562
+ ### Local execution as an alternative
5814
6563
 
5815
- If a workflow accesses a secret context that has no test-allowed equivalent, the run fails immediately. This prevents accidental use of production secrets in test runs.
6564
+ `kici run local` resolves the same local secret files entirely on your machine and honors the fixture `secrets: { ... }` mapping to pick which local context backs each name (see [`kici run local`](cli-reference.md#kici-run-local)). Because the values never leave your machine, it's a good fit when you want to exercise secret-dependent steps without involving the orchestrator at all.
5816
6565
 
5817
6566
  ### Discovering available contexts
5818
6567
 
@@ -5922,6 +6671,58 @@ Practical patterns for building real-world KiCI workflows in TypeScript. The pat
5922
6671
 
5923
6672
  # Workflow features
5924
6673
 
6674
+ ## Account and sign-in
6675
+
6676
+ Source: https://docs.kici.dev/user/account-and-login/
6677
+
6678
+ Your KiCI account is a single identity. It stays the same no matter how you
6679
+ sign in — whether you signed up with GitHub or with an email and password.
6680
+ Changing your sign-in method does not create a new account or move your data;
6681
+ your organizations, roles, and API keys stay attached to the same identity.
6682
+
6683
+ ## Where sign-in methods are managed
6684
+
6685
+ Sign-in methods and passwords are managed in your **account console**, provided
6686
+ by the identity provider that handles single sign-on for KiCI. The dashboard's
6687
+ **Linked accounts** page does not control how you sign in — see
6688
+ [Linked accounts vs sign-in methods](#linked-accounts-vs-sign-in-methods) below.
6689
+
6690
+ You can open the account console from the dashboard: go to your personal
6691
+ settings, open **Linked accounts**, and use the **Account console** link.
6692
+
6693
+ ## Adding a password to a GitHub-created account
6694
+
6695
+ If you registered by signing in with GitHub and now want to sign in with an
6696
+ email and password as well:
6697
+
6698
+ 1. Open your account console.
6699
+ 2. Add a password (and, if prompted, confirm your email).
6700
+
6701
+ After this, you can sign in either with GitHub or with your email and password —
6702
+ it is the same account.
6703
+
6704
+ ## Removing GitHub as a sign-in method
6705
+
6706
+ To stop using GitHub to sign in:
6707
+
6708
+ 1. First add a password (see above). The identity provider will not let you
6709
+ remove your only sign-in method, so you must have another one first.
6710
+ 2. In your account console, remove the GitHub sign-in method.
6711
+
6712
+ Your account, organizations, and data are unaffected — you simply sign in a
6713
+ different way afterward.
6714
+
6715
+ ## Linked accounts vs sign-in methods
6716
+
6717
+ The dashboard's **Linked accounts** page controls **run-attribution metadata**
6718
+ only — for example, showing your GitHub username on the runs you trigger and
6719
+ determining your contributor trust level. Unlinking a provider there removes
6720
+ that display link; it does **not** remove the provider as a way to sign in.
6721
+
6722
+ To actually change how you sign in, use your account console as described above.
6723
+
6724
+ ---
6725
+
5925
6726
  ## Concurrency groups
5926
6727
 
5927
6728
  Source: https://docs.kici.dev/user/concurrency/
@@ -5974,8 +6775,8 @@ group: (ctx) => `deploy-${ctx.branch}`;
5974
6775
  // Global concurrency (across all branches)
5975
6776
  group: () => 'deploy';
5976
6777
 
5977
- // Per-environment concurrency
5978
- group: (ctx) => `deploy-${ctx.event.environment ?? 'default'}`;
6778
+ // Per-target-branch concurrency
6779
+ group: (ctx) => `deploy-${ctx.event.targetBranch ?? 'default'}`;
5979
6780
  ```
5980
6781
 
5981
6782
  The workflow-level group function is always evaluated **agent-side** at runtime -- the lock file records only that a group function exists (`hasGroup: true`), not the function itself. The agent loads the workflow source, calls the group function with `{ branch, event }`, and reports the evaluated key back to the orchestrator before step execution begins. This differs from job-level `concurrencyGroup` (see [Environments](environments.md#concurrency-groups)), where the compiler performs purity analysis and can inline pure functions for orchestrator-side evaluation.
@@ -6172,7 +6973,7 @@ The six steps are:
6172
6973
 
6173
6974
  1. **Install the kici CLI** -- `npm install -g kici`.
6174
6975
  2. **Create a workflow** -- `kici init` scaffolds a `.kici/` directory in your repository.
6175
- 3. **Run a workflow locally** -- `kici test pr:open --dry-run` executes a workflow on your machine with no orchestrator required.
6976
+ 3. **Run a workflow locally** -- `kici run local pr:open` executes a workflow on your machine with no orchestrator required.
6176
6977
  4. **Connect an orchestrator** -- deploy an orchestrator and connect it with a join token from **Settings → Orchestrator keys**.
6177
6978
  5. **Add a webhook source** -- register a source under **Settings → Sources** so pushes and pull requests trigger runs.
6178
6979
  6. **Trigger your first run** -- push to your repository to produce your first run through the relay.
@@ -6462,7 +7263,7 @@ The job tree supports keyboard navigation:
6462
7263
  The content area has the following tabs:
6463
7264
 
6464
7265
  - **Logs** (default) -- shows log output for the selected job or step
6465
- - **Payload** -- webhook payload viewer showing the raw event payload that triggered the run
7266
+ - **Payload** -- webhook payload viewer showing the raw event payload that triggered the run. This tab appears only for runs triggered by a webhook event (and re-runs of those, which copy the original payload); runs started by a schedule, manual schedule, lifecycle event, or another run carry no payload, so the tab is hidden for them
6466
7267
  - **Timeline** -- CSS Gantt chart showing the execution timeline of all jobs, with percentage-based bars and striped animation for running jobs. A **Provisioning** milestones section between the dispatch and execution phases plots scaler lifecycle events for the run — including a **Provisioning failed** marker when the scaler could not bring an agent up
6467
7268
  - **Summary** -- contextual overview scoped to the current selection (run-level trigger/repo/timing info, or job-level execution context with environment variables, runtime info, and sandbox details)
6468
7269
 
@@ -6577,18 +7378,30 @@ Each member's linked provider accounts (e.g. GitHub) are also visible here.
6577
7378
 
6578
7379
  <!-- help:settings-roles#settings -->
6579
7380
 
6580
- Roles define granular permissions across 14 resource categories (runs, secrets, members, etc.) with 5 access levels: `none`, `read`, `read_payload`, `write`, `admin`.
7381
+ Roles define granular permissions across 15 resource categories (runs, secrets, members, etc.) with 5 access levels: `none`, `read`, `read_payload`, `write`, `admin`.
6581
7382
 
6582
7383
  Create custom roles to restrict what team members can do, or use the built-in **Owner** role for full access.
6583
7384
 
6584
7385
  <!-- /help:settings-roles -->
6585
7386
 
7387
+ <!-- help:settings-teams#settings -->
7388
+
7389
+ Teams are named groups of organization members. A role granted to a team is inherited by every member, so you can manage permissions for a whole group in one place.
7390
+
7391
+ Team names can also be referenced in workflow approval gates (`requireApproval: [{ team: 'leads' }]`) — any member of the named team can satisfy that gate.
7392
+
7393
+ Managing teams (create / rename / delete, membership, role grants) requires the **Teams** permission at `admin`; `read` shows a view-only list.
7394
+
7395
+ <!-- /help:settings-teams -->
7396
+
6586
7397
  <!-- help:settings-api-keys#settings -->
6587
7398
 
6588
7399
  API keys allow programmatic access to the KiCI API for automation, scripts, and CI integrations.
6589
7400
 
6590
7401
  Each key is scoped to this organization with a custom permission matrix and an optional expiry date. Keys can be revoked individually.
6591
7402
 
7403
+ Use a key's clone button to open the creation modal prefilled with that key's name, expiry, and permissions — handy for recreating an expired key or deriving a new key from an existing one.
7404
+
6592
7405
  <!-- /help:settings-api-keys -->
6593
7406
 
6594
7407
  <!-- help:settings-orchestrator-keys#orchestrator-keys -->
@@ -6597,6 +7410,8 @@ Orchestrator keys authenticate the WebSocket connection between your orchestrato
6597
7410
 
6598
7411
  Create a key here and set it as the `KICI_PLATFORM_TOKEN` environment variable in your orchestrator configuration. Keys can optionally be restricted to specific routing patterns.
6599
7412
 
7413
+ Use a key's clone button to open the creation modal prefilled with that key's name and description.
7414
+
6600
7415
  <!-- /help:settings-orchestrator-keys -->
6601
7416
 
6602
7417
  <!-- help:settings-sources#sources -->
@@ -6649,6 +7464,8 @@ The relayed-webhooks counter only includes webhooks delivered through the KiCI P
6649
7464
 
6650
7465
  Webhooks pointed directly at your orchestrator's public ingest endpoint never reach the Platform, so they're invisible to this counter and uncapped on every Hosted tier. If you have a public orchestrator ingress, you can mix-and-match: use the relay for sources you can't expose publicly, and point GitHub (or any provider / generic webhook) straight at your orchestrator for the rest.
6651
7466
 
7467
+ Every webhook the relay forwards counts — **including ones your workflows ultimately ignore**. Trigger matching runs on your orchestrator, not on the Platform, so the relay forwards each signature-verified webhook before any trigger is evaluated. A source that sends many events you filter down to a handful of runs still consumes one relayed webhook per event. If a high-volume source mostly produces no run, point it directly at your orchestrator (see above) to keep it off this counter entirely.
7468
+
6652
7469
  When you hit the cap, new relayed webhooks are rejected with `429 Plan limit reached`. Upgrade in the Stripe Billing Portal to lift the cap immediately; usage resets monthly on your billing anniversary.
6653
7470
 
6654
7471
  <!-- /help:settings-billing-relayed-webhooks -->
@@ -6818,6 +7635,20 @@ The summary strip at the top shows total / enabled / disabled counts plus whethe
6818
7635
 
6819
7636
  <!-- /help:settings-security-dashboard-policy -->
6820
7637
 
7638
+ <!-- help:settings-support-access#settings -->
7639
+
7640
+ Controls whether KiCI support staff may open read-only support sessions against your organization. Sessions are **off by default** — nobody outside your org can read your data until you opt in here.
7641
+
7642
+ When enabled:
7643
+
7644
+ - KiCI staff can open time-boxed, read-only sessions to investigate an issue.
7645
+ - Every read they perform is recorded in your audit trail with the support reason.
7646
+ - No writes are ever possible during a session.
7647
+
7648
+ Disabling the toggle immediately ends any in-progress support session. Only users with the `support:admin` permission (owners by default) can change this setting.
7649
+
7650
+ <!-- /help:settings-support-access -->
7651
+
6821
7652
  <!-- help:settings-webhooks-delivery-log#settings -->
6822
7653
 
6823
7654
  The delivery log shows recent webhook deliveries for an endpoint, including the HTTP status code, number of retry attempts, and the event payload.
@@ -6874,11 +7705,26 @@ The settings page (`/orgs/:customerId/settings`) uses a tabbed layout:
6874
7705
  10. **Webhooks** -- outbound webhook endpoint management with delivery logs and test ping
6875
7706
  11. **Event log** -- inbound webhook delivery log (visible with `event_log:read` permission)
6876
7707
  12. **Security** -- read-only view of the orchestrator's dashboard-write policy matrix (visible with `org_settings:read` permission)
7708
+ 13. **Support access** -- opt-in switch that controls whether KiCI support staff may open read-only support sessions against your org (visible with `support:read`; toggled with `support:admin`)
6877
7709
 
6878
7710
  Audit-log-style entries are no longer a settings tab; they live on the dedicated **Activity** page accessible from the sidebar.
6879
7711
 
6880
7712
  Tab selection syncs with the URL path (`/settings/members`, `/settings/api-keys`, etc.), making tabs bookmarkable.
6881
7713
 
7714
+ ### Support access
7715
+
7716
+ The Support access tab controls whether KiCI support staff may open a read-only **support session** against your organization to help diagnose an issue. The setting is **off by default** -- until you opt in here, no one outside your org can read your data.
7717
+
7718
+ When support access is enabled:
7719
+
7720
+ - A KiCI operator can open a time-boxed (30-minute, renewable), read-only support session scoped to a stated reason.
7721
+ - A support session is **runs-only**: the operator can browse your run list and, by confirming each run individually, view that run's detail and step logs. Nothing else is visible, and no write is ever possible.
7722
+ - Every run an operator opens is recorded in your [Activity](#activity) audit trail, attributed to the operator with the support reason -- so you can see exactly what was looked at and why.
7723
+
7724
+ **Disabling immediately ends any active session.** Toggling the switch off closes every in-progress support session for your org at once. Enabling and disabling the setting is itself audited, attributed to the user who changed it.
7725
+
7726
+ Viewing the setting requires the `support:read` permission; changing it requires `support:admin` (granted to owners by default).
7727
+
6882
7728
  ### Orchestrator keys
6883
7729
 
6884
7730
  The orchestrator keys tab manages API keys used to authenticate orchestrator-to-Platform WebSocket connections. These are separate from user API keys (which grant dashboard/API access).
@@ -6955,11 +7801,15 @@ Personal access tokens (PATs) are long-lived credentials for programmatic API ac
6955
7801
 
6956
7802
  Create a PAT to authenticate CLI tools or scripts without going through the OIDC login flow. Tokens can be revoked at any time.
6957
7803
 
7804
+ Use a token's clone button to open the creation modal prefilled with that token's name, expiry, and permissions.
7805
+
6958
7806
  <!-- /help:personal-pats -->
6959
7807
 
6960
7808
  <!-- help:personal-linked-accounts#account -->
6961
7809
 
6962
- Linked accounts connect your external provider identities (like GitHub) to your KiCI account. Linking enables features like showing your provider username in run metadata and associating your commits with your KiCI identity.
7810
+ Linked accounts connect your external provider identities (like GitHub) to your KiCI account. Linking shows your provider username in run metadata and sets your contributor trust level.
7811
+
7812
+ **Unlinking here removes the display link only** — it does not remove a sign-in method. To change how you sign in (add a password, remove GitHub login), use the **Account console** link, or see [Account and sign-in](./account-and-login.md).
6963
7813
 
6964
7814
  <!-- /help:personal-linked-accounts -->
6965
7815
 
@@ -7053,7 +7903,7 @@ The policy state is visible at three layers in the UI:
7053
7903
  - A **lock-icon prefix** on every disabled control, with a per-control CLI hint.
7054
7904
  - A **per-page banner** on any page containing at least one disabled operation, listing every disabled op on that page and its CLI equivalent.
7055
7905
 
7056
- The Security policy page (Settings → Security → Dashboard policy) renders the full 23-row read-only matrix with the current state and the `kici-admin` command for each row. The policy itself cannot be changed from the dashboard — the orchestrator operator manages it via `kici-admin org-settings dashboard-writes`. See [Dashboard-write policy](/operator/security/dashboard-write-policy) for the operator-side details.
7906
+ The Security policy page (Settings → Security → Dashboard policy) renders the full 24-row read-only matrix with the current state and the `kici-admin` command for each row. The policy itself cannot be changed from the dashboard — the orchestrator operator manages it via `kici-admin org-settings dashboard-writes`. See [Dashboard-write policy](/operator/security/dashboard-write-policy) for the operator-side details.
7057
7907
 
7058
7908
  ## Approval queue
7059
7909
 
@@ -7067,6 +7917,8 @@ The standalone account page (`/account`) provides access to personal settings ou
7067
7917
  - **Personal access tokens** -- create and revoke PATs for programmatic API access
7068
7918
  - **Linked accounts** -- connect external provider identities (e.g. GitHub) to your KiCI account
7069
7919
 
7920
+ Linked accounts control run-attribution metadata only — unlinking a provider here does not remove it as a way to sign in. To change how you sign in, see [Account and sign-in](./account-and-login.md).
7921
+
7070
7922
  This page is also accessible within an org context via the user menu in the sidebar (`/orgs/:customerId/account`).
7071
7923
 
7072
7924
  ## Admin section
@@ -7133,14 +7985,25 @@ The dashboard shows informative error pages instead of blank screens:
7133
7985
 
7134
7986
  Source: https://docs.kici.dev/user/dynamic-values/
7135
7987
 
7136
- Dynamic values let you compute `environment`, `env`, and `concurrencyGroup` at runtime based on the incoming event. Instead of hardcoding static strings, you pass a function that receives the webhook event payload and returns the resolved value.
7988
+ Dynamic values let you compute `environment`, `env`, and `concurrencyGroup` at runtime based on the incoming event. Instead of hardcoding static strings, you pass a function that receives the normalized event envelope and returns the resolved value.
7137
7989
 
7138
7990
  ```typescript
7139
7991
  job('deploy', {
7140
7992
  runsOn: ['default'],
7141
- environment: (event) => event.ref.split('/').pop(),
7142
- env: (event) => ({ BRANCH: event.ref.split('/').pop() }),
7143
- concurrencyGroup: (event) => `deploy-${event.ref.split('/').pop()}`,
7993
+ environment: (event) => event.targetBranch,
7994
+ env: (event) => ({ BRANCH: event.targetBranch }),
7995
+ concurrencyGroup: (event) => `deploy-${event.targetBranch}`,
7996
+ steps: [
7997
+ /* ... */
7998
+ ],
7999
+ });
8000
+ ```
8001
+
8002
+ ```typescript
8003
+ job('deploy', {
8004
+ runsOn: 'default',
8005
+ // One shape everywhere: branch on the normalized event type.
8006
+ environment: (event) => (event.type === 'pull_request' ? 'preview' : 'production'),
7144
8007
  steps: [
7145
8008
  /* ... */
7146
8009
  ],
@@ -7167,21 +8030,21 @@ When the compiler detects a pure function, it serializes the function source dir
7167
8030
  **Examples of pure functions:**
7168
8031
 
7169
8032
  ```typescript
7170
- // Simple branch extraction from ref
7171
- environment: (event) => event.ref.split('/').pop();
8033
+ // Simple branch extraction
8034
+ environment: (event) => event.targetBranch;
7172
8035
 
7173
8036
  // Object literal with string operations
7174
- env: ({ ref }) => ({ BRANCH: ref.split('/').pop() });
8037
+ env: (event) => ({ BRANCH: event.targetBranch });
7175
8038
 
7176
8039
  // Concatenation with event data
7177
- concurrencyGroup: (event) => `deploy-${event.ref.split('/').pop()}`;
8040
+ concurrencyGroup: (event) => `deploy-${event.targetBranch}`;
7178
8041
 
7179
8042
  // Using safe globals
7180
- env: (event) => ({ UPPER: String(event.ref).toUpperCase() });
8043
+ env: (event) => ({ UPPER: String(event.targetBranch).toUpperCase() });
7181
8044
 
7182
8045
  // Local variables are fine
7183
8046
  environment: (event) => {
7184
- const parts = event.ref.split('/');
8047
+ const parts = event.targetBranch.split('/');
7185
8048
  return parts[parts.length - 1];
7186
8049
  };
7187
8050
  ```
@@ -7201,7 +8064,7 @@ This adds approximately 5-10 seconds of overhead for cloning and evaluation.
7201
8064
 
7202
8065
  ```typescript
7203
8066
  // Async functions cannot be inlined
7204
- environment: async (event) => await lookupEnv(event.ref);
8067
+ environment: async (event) => await lookupEnv(event.targetBranch);
7205
8068
 
7206
8069
  // External module references
7207
8070
  env: (event) => {
@@ -7221,18 +8084,18 @@ env: async (event) => {
7221
8084
 
7222
8085
  ## Performance comparison
7223
8086
 
7224
- | Evaluation path | Overhead | When used |
7225
- | ------------------------------------ | -------- | ---------------------------------------------------------- |
7226
- | Static value (string/object literal) | ~0ms | `environment: 'staging'` |
7227
- | Inline expression (pure function) | ~0ms | `environment: (event) => event.ref.split('/').pop()` |
7228
- | Init job (impure function) | ~5-10s | `environment: async (event) => await lookupEnv(event.ref)` |
8087
+ | Evaluation path | Overhead | When used |
8088
+ | ------------------------------------ | -------- | ------------------------------------------------------------------- |
8089
+ | Static value (string/object literal) | ~0ms | `environment: 'staging'` |
8090
+ | Inline expression (pure function) | ~0ms | `environment: (event) => event.targetBranch` |
8091
+ | Init job (impure function) | ~5-10s | `environment: async (event) => await lookupEnv(event.targetBranch)` |
7229
8092
 
7230
8093
  ## Tips
7231
8094
 
7232
8095
  - **Write pure functions whenever possible** to avoid the init-job delay. Most environment and env computations only need the event payload data.
7233
8096
  - **Check compiler warnings** -- the compiler tells you when a function is classified as impure and explains why.
7234
8097
  - **Runtime errors in inline expressions cause immediate job failure.** There is no fallback to the init-job path. If your pure function throws at runtime (e.g., accessing a property on `undefined`), the job fails immediately.
7235
- - **The event parameter** contains the normalized webhook payload. For push events this includes `ref`, `repository`, `sender`, etc. For PR events it includes `pull_request`, `action`, etc.
8098
+ - **The event parameter is the normalized event envelope** — the same shape rules receive as `ctx.event`: `{ type, action, targetBranch, sourceBranch, changedFiles, payload, … }` (see the [event payload reference](./sdk/event-payloads.md) for the complete schema). Narrow on `event.type` (`'push'`, `'pull_request'`, `'tag'`, …) to branch per trigger kind. The raw provider webhook body is nested at `event.payload` (for GitHub pushes: `payload.ref`, `payload.after`, `payload.repository`, …).
7236
8099
 
7237
8100
  ---
7238
8101
 
@@ -7240,16 +8103,16 @@ env: async (event) => {
7240
8103
 
7241
8104
  Source: https://docs.kici.dev/user/env-vars/
7242
8105
 
7243
- The KiCI CLI reads the following environment variables to customize its behavior. OAuth login (`kici login` without `--token`) requires `KICI_PLATFORM_URL`, `KICI_OIDC_ISSUER`, and `KICI_OIDC_CLIENT_ID` to be set the CLI does not ship defaults for them.
8106
+ The KiCI CLI reads the following environment variables to customize its behavior. OAuth login (`kici login` without `--token`) defaults `KICI_PLATFORM_URL`, `KICI_OIDC_ISSUER`, and `KICI_OIDC_CLIENT_ID` to the hosted KiCI Platform, so `kici login` works with no configuration. Set them only to target a self-hosted Platform or a testing environment.
7244
8107
 
7245
8108
  ## Authentication
7246
8109
 
7247
- | Variable | Description | Default |
7248
- | --------------------- | ----------------------------------------------------------- | ------------------ |
7249
- | `KICI_OIDC_ISSUER` | OIDC issuer URL for authentication (required for OAuth) | none — must be set |
7250
- | `KICI_OIDC_CLIENT_ID` | OIDC client ID for the CLI application (required for OAuth) | none — must be set |
7251
- | `KICI_PLATFORM_URL` | Platform API base URL (required for OAuth) | none — must be set |
7252
- | `KICI_CONFIG_DIR` | Override the KiCI config directory | `~/.kici` |
8110
+ | Variable | Description | Default |
8111
+ | --------------------- | -------------------------------------- | -------------------------------------------- |
8112
+ | `KICI_OIDC_ISSUER` | OIDC issuer URL for authentication | `https://auth.kici.dev/realms/kici-internal` |
8113
+ | `KICI_OIDC_CLIENT_ID` | OIDC client ID for the CLI application | `kici-cli` |
8114
+ | `KICI_PLATFORM_URL` | Platform API base URL | `https://api.kici.dev` |
8115
+ | `KICI_CONFIG_DIR` | Override the KiCI config directory | `~/.kici` |
7253
8116
 
7254
8117
  ## Browser behavior
7255
8118
 
@@ -7275,9 +8138,9 @@ Authenticate with a pre-existing API key (no browser needed):
7275
8138
  kici login --token <<< "$KICI_API_KEY"
7276
8139
  ```
7277
8140
 
7278
- ### Custom OIDC provider
8141
+ ### Self-hosted Platform or custom OIDC provider
7279
8142
 
7280
- Point the CLI to a self-hosted or testing OIDC provider:
8143
+ `kici login` targets the hosted KiCI Platform by default. To point the CLI at a self-hosted Platform or a testing OIDC provider, override the defaults:
7281
8144
 
7282
8145
  ```bash
7283
8146
  export KICI_OIDC_ISSUER=https://your-idp.example.com
@@ -7384,12 +8247,12 @@ export default workflow('deploy', {
7384
8247
 
7385
8248
  ### Dynamic environments
7386
8249
 
7387
- The environment name can be a string or a function (sync or async) for dynamic environments (e.g., per-PR review environments):
8250
+ The environment name can be a string or a function (sync or async) for dynamic environments (e.g., per-PR review environments). The function receives the normalized event envelope, with the raw provider body nested at `event.payload`:
7388
8251
 
7389
8252
  ```typescript
7390
8253
  job('deploy-review', {
7391
8254
  runsOn: 'default',
7392
- environment: async (event) => `review/PR-${event.pull_request.number}`,
8255
+ environment: (event) => `review/PR-${event.payload.pull_request.number}`,
7393
8256
  steps: [
7394
8257
  step('deploy', async (ctx) => {
7395
8258
  // ctx.environment is 'review/PR-123' (resolved at runtime)
@@ -7399,7 +8262,7 @@ job('deploy-review', {
7399
8262
  });
7400
8263
  ```
7401
8264
 
7402
- Dynamic environments that match a glob pattern (e.g., `review/*`) inherit the pattern's configuration, variables, and protection rules.
8265
+ A pure function like the one above (see [Dynamic values](dynamic-values.md)) is evaluated inline at dispatch with no init-job overhead. Dynamic environments that match a glob pattern (e.g., `review/*`) inherit the pattern's configuration, variables, and protection rules.
7403
8266
 
7404
8267
  ### Job-level environment variables
7405
8268
 
@@ -7411,7 +8274,7 @@ job('deploy', {
7411
8274
  environment: 'production',
7412
8275
  env: { DEPLOY_TARGET: 'us-east-1' },
7413
8276
  // Or dynamic:
7414
- // env: async (event) => ({ DEPLOY_SHA: event.after?.slice(0, 7) }),
8277
+ // env: (event) => ({ DEPLOY_SHA: event.payload.after?.slice(0, 7) }),
7415
8278
  steps: [
7416
8279
  step('deploy', async (ctx) => {
7417
8280
  // DEPLOY_TARGET is available in ctx.env
@@ -7433,7 +8296,7 @@ job('deploy', {
7433
8296
  environment: 'production',
7434
8297
  concurrencyGroup: 'production-api',
7435
8298
  // Or dynamic:
7436
- // concurrencyGroup: async (event) => `review-${event.pull_request.number}`,
8299
+ // concurrencyGroup: (event) => `review-${event.payload.pull_request.number}`,
7437
8300
  steps: [
7438
8301
  /* ... */
7439
8302
  ],
@@ -7496,7 +8359,9 @@ Require manual approval before a job can proceed:
7496
8359
  Required reviewers: alice, bob
7497
8360
  ```
7498
8361
 
7499
- When reviewers are required, the job enters a "held" state. Reviewers can approve or reject via the dashboard or API. Held runs expire after a configurable timeout (default: 1 hour).
8362
+ When reviewers are required, the job enters a "held" state. Reviewers can approve or reject via the dashboard, the [`kici approve`](cli-reference.md#kici-approve) command, or the API. Held runs expire after a configurable timeout.
8363
+
8364
+ This operator-set rule is the **mandatory** form of an approval gate. Workflow authors can also declare gates in code with `requireApproval` at step, job, or workflow level — see [Approval gates](approvals.md). Both forms use the same held-element mechanism and the same queue.
7500
8365
 
7501
8366
  ### Wait timer
7502
8367
 
@@ -7560,10 +8425,12 @@ Strategy: queue (or cancel-pending)
7560
8425
 
7561
8426
  ### Creating environments
7562
8427
 
7563
- Navigate to **Settings > Environments** in the dashboard. Click **+ New** to create an inline row with the environment name and type (Fixed or Glob).
8428
+ Navigate to **Settings > Environments** in the dashboard. Click **New environment** to choose the environment name and type (Fixed or Glob).
8429
+
8430
+ - **Fixed** -- applies to jobs that declare exactly this environment name, like `staging` or `production`
8431
+ - **Glob** -- applies to any environment name a job declares that matches the pattern, e.g. `review/*` matches a job with `environment: 'review/PR-123'`
7564
8432
 
7565
- - **Fixed** -- a named environment like `staging` or `production`
7566
- - **Glob** -- a pattern like `review/*` that matches dynamic environment names
8433
+ The environments list shows each environment's type, whether test runs may use it (the `allowLocalExecution` flag -- see the [testing guide](./testing-guide.md)), and whether it is enabled.
7567
8434
 
7568
8435
  ### Environment detail page
7569
8436
 
@@ -7804,7 +8671,7 @@ export default workflow('nightly-build', {
7804
8671
  on: schedule({ cron: '0 2 * * *' }),
7805
8672
  jobs: [
7806
8673
  job('build', {
7807
- runsOn: 'ubuntu-latest',
8674
+ runsOn: 'linux',
7808
8675
  steps: [
7809
8676
  step('build', async ({ $ }) => {
7810
8677
  await $`pnpm build`;
@@ -7903,7 +8770,7 @@ export default workflow('build', {
7903
8770
  on: push({ branches: 'main' }),
7904
8771
  jobs: [
7905
8772
  job('build', {
7906
- runsOn: 'ubuntu-latest',
8773
+ runsOn: 'linux',
7907
8774
  steps: [
7908
8775
  step('build', async ({ $ }) => {
7909
8776
  await $`pnpm build`;
@@ -7958,7 +8825,7 @@ export default workflow('post-deploy', {
7958
8825
  on: kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }),
7959
8826
  jobs: [
7960
8827
  job('smoke-test', {
7961
- runsOn: 'ubuntu-latest',
8828
+ runsOn: 'linux',
7962
8829
  steps: [
7963
8830
  step('test', async ({ $ }) => {
7964
8831
  await $`./scripts/smoke-test.sh`;
@@ -8246,7 +9113,7 @@ export default workflow('build', {
8246
9113
  });
8247
9114
  ```
8248
9115
 
8249
- The same pattern works for GCP Artifact Registry — replace the `aws codeartifact` call with `gcloud auth print-access-token`. A typed `RegistryProvider` for these short-lived flows is on the roadmap; the manual setup-step is the supported path until then.
9116
+ The same pattern works for GCP Artifact Registry — replace the `aws codeartifact` call with `gcloud auth print-access-token`. The manual setup-step shown here is the supported path for these short-lived flows.
8250
9117
 
8251
9118
  ## Provider-specific examples
8252
9119
 
@@ -8774,7 +9641,7 @@ kici-admin source get-webhook-secret github:12345 # Fetch the secret (for debug
8774
9641
  ```
8775
9642
 
8776
9643
  For the full CLI reference see the `source` section of the
8777
- [kici-admin CLI reference](../../operator/kici-admin-cli.md).
9644
+ [kici-admin CLI reference](../../operator/orchestrator/kici-admin-cli.md).
8778
9645
 
8779
9646
  ## Routing keys
8780
9647
 
@@ -8927,7 +9794,7 @@ and confirm the App is installed on that repo.
8927
9794
  Gogs / GitLab, and for plain-GitHub repos without an App
8928
9795
  - [GitHub checks architecture](../../architecture/webhooks/github-checks.md)
8929
9796
  - [Global workflows](../../architecture/global-workflows.md)
8930
- - [kici-admin CLI reference](../../operator/kici-admin-cli.md)
9797
+ - [kici-admin CLI reference](../../operator/orchestrator/kici-admin-cli.md)
8931
9798
  - [Event routing](../../operator/event-routing.md) — operator-level
8932
9799
  routing-key mechanics
8933
9800
 
@@ -9180,7 +10047,7 @@ Source: https://docs.kici.dev/architecture/data-flows/
9180
10047
 
9181
10048
  This document describes the key data flows through the KiCI architecture: webhook delivery, job execution, dependency caching, re-run and cancel, trace ID propagation, internal event routing, and generic webhook ingestion.
9182
10049
 
9183
- > **Lock file schema version:** The lock file uses schema version 12, which adds `registries` and `installEnv` fields for private registry support on top of v11's `LockInlineValue` for pure function inline evaluation, v10's simplified negative patterns (! prefix in repos/paths arrays), v9's global workflow repos matching, and v8's runsOn polymorphic type support.
10050
+ > **Lock file schema version:** The lock file uses schema version 15, which adds per-job init config on top of v14's declarative cache specs, v11's `LockInlineValue` for pure function inline evaluation, v10's simplified negative patterns (! prefix in repos/paths arrays), v9's global workflow repos matching, and v8's runsOn polymorphic type support.
9184
10051
 
9185
10052
  ## Webhook delivery flow
9186
10053
 
@@ -9206,7 +10073,7 @@ GitHub --> Platform Relay --> Orchestrator --> Agent
9206
10073
  10. **Orchestrator fetches lock file** via the provider's `LockFileFetcher` (cached with LRU). For untrusted PR events, fetches both base and head lock files in parallel; for trusted PRs and pushes, fetches from head SHA.
9207
10074
  11. **Orchestrator detects workflow modifications** for untrusted PR events by comparing base and head lock files via `detectWorkflowModifications()`, applying security holds when non-trusted contributors modify workflow files.
9208
10075
  12. **Orchestrator extracts registrations** on default-branch pushes: persists registerable workflows (event, schedule, lifecycle triggers) for cluster-wide event matching.
9209
- 13. **Orchestrator registers event subscriptions** from lock file (if event routing is active) via `eventRouter.registerSubscriptions()`.
10076
+ 13. **Orchestrator notifies the event router** on default-branch pushes: after the registrations are persisted, emits a `registration.updated` event via `eventRouter.emit()` (if event routing is active). Workflow event subscriptions are the persisted registrations themselves, matched at emit time through the registration index.
9210
10077
  14. **Orchestrator fetches changed files** via the provider's `ChangedFilesFetcher` for path-based trigger filtering (skipped when no workflow uses path filters).
9211
10078
  15. **Orchestrator matches triggers** against lock file using `matchAllWorkflows()` from `@kici-dev/engine`.
9212
10079
  16. **Orchestrator checks caches** for source tarballs and dependency tarballs.
@@ -9349,9 +10216,9 @@ The source cache and dep cache are independent. Four combinations are possible:
9349
10216
 
9350
10217
  Dep cache misses alone do **not** trigger a build job. Deps are platform-specific (`deps/{platform}-{arch}/{hash}.tar.gz`) so a build job would need a builder agent matching the target platform, which may not exist (e.g., an arm64 builder when only x64 builders are available). When the source cache misses, the dispatched build job piggy-backs dep packing if deps are also missing. A single build job handles both artifacts when both miss, avoiding duplicate builds.
9351
10218
 
9352
- ### Legacy / cross-source / no-contentHash workflows
10219
+ ### Cross-source / no-contentHash workflows
9353
10220
 
9354
- - **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 12 (see [Design decisions](design-decisions.md#lock-file-schema-versioning)).
10221
+ - **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 15.
9355
10222
  - **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.
9356
10223
 
9357
10224
  ### Build deduplication
@@ -9435,6 +10302,64 @@ The two-phase metadata approach (`upload via PUT` then `initMeta via CopyObject`
9435
10302
 
9436
10303
  Agents receive pre-signed S3 GET URLs (15-minute expiry) directly in `job.dispatch` messages. Agents download artifacts from S3, bypassing the orchestrator for all data transfer.
9437
10304
 
10305
+ ## User-facing cache flow
10306
+
10307
+ The source/dep cache above is internal: the orchestrator owns its keys and decides when to hit or build. The **user-facing cache** is driven by the workflow author — the declarative `cache: { key, paths, restoreKeys? }` on a job/step, or the imperative `ctx.cache.restore()` / `ctx.cache.save()` API (see [SDK caching reference](../user/sdk/caching.md)). It reuses the same object-storage backend and the same direct-to-storage presigned-URL transport, but the agent — not the orchestrator — initiates each restore and save over WebSocket.
10308
+
10309
+ The agent's cache module archives `paths` into a gzipped tarball (computing a SHA-256 over the bytes) and streams downloads back through a checksum-verified extract pipeline. The orchestrator's `UserCache` owns the `cache/<orgId>/<repoId>/<scope>/<key>` namespacing, the immutable first-save check, the `restoreKeys` prefix scan, the two-phase atomic save, and per-org quota/TTL eviction.
10310
+
10311
+ ### Restore flow
10312
+
10313
+ ```
10314
+ Agent Orchestrator (UserCache) Object storage
10315
+ | | |
10316
+ |-- cache.user.restore.request ------->| |
10317
+ | { key, restoreKeys? } |-- exact key in read prefixes ->|
10318
+ | | (isolated: iso/<runId>/ |
10319
+ | | then shared/; trusted: |
10320
+ | | shared/ only) |
10321
+ | |-- restoreKeys prefix scan ---->|
10322
+ | | (newest match wins) |
10323
+ | |-- getUrl(matched) + touch ---->|
10324
+ |<-- cache.user.restore.response ------| |
10325
+ | { hit, matchedKey?, | |
10326
+ | downloadUrl?, tarHash? } | |
10327
+ | | |
10328
+ |-- HTTP GET (tarball body) -------------------------------------->|
10329
+ | (direct download; verify tarHash, extract paths) |
10330
+ ```
10331
+
10332
+ The restore resolves the exact `key` across the ref's read prefixes first, then each `restoreKeys` prefix in order (newest matching entry wins). A trusted ref reads only `shared/`; an untrusted/fork ref reads its own `iso/<runId>/` scope and then falls back to `shared/`. On a hit the response carries a presigned GET URL plus the tarball's `tarHash`, which the agent verifies before extracting.
10333
+
10334
+ ### Save flow (two-phase atomic)
10335
+
10336
+ ```
10337
+ Agent Orchestrator (UserCache) Object storage
10338
+ | | |
10339
+ |-- cache.user.save.request --------->| |
10340
+ | { key } |-- has(final key)? ------------>|
10341
+ | | (immutable: skip if exists) |
10342
+ | |-- getUploadUrl(.tmp-<uuid>) -->|
10343
+ |<-- cache.user.save.response ---------| |
10344
+ | { uploadUrl?, skip } | |
10345
+ | | |
10346
+ |-- HTTP PUT (tarball body) ----------------------------------->|
10347
+ | (direct upload to temp object) |
10348
+ | | |
10349
+ |-- cache.user.save.complete -------->| |
10350
+ | { key, tarHash, sizeBytes } |-- copy(temp -> final) -------->|
10351
+ | |-- delete(temp) --------------->|
10352
+ | |-- initMeta(final) ------------>|
10353
+ | |-- put(.hash) + put(.size) ---->|
10354
+ | |-- enforce per-org quota ------>|
10355
+ ```
10356
+
10357
+ The save is **immutable** and **atomic**. The orchestrator declines (`skip: true`) up front if the exact key already exists. Otherwise the agent uploads to a `.tmp-<uuid>` object via a presigned PUT, then `cache.user.save.complete` triggers a server-side copy temp→final, a delete of the temp, an `initMeta` to stamp TTL metadata, and `.hash` / `.size` companion writes. Because the final key only appears after the copy, a crashed upload never leaves a corrupt committed entry. The committing save then enforces the per-org byte quota, evicting oldest entries until the org is back under `KICI_USER_CACHE_QUOTA_BYTES`.
10358
+
10359
+ ### Trust → scope mapping
10360
+
10361
+ The orchestrator threads a `cacheRefScope` onto each `job.dispatch`. A **trusted** ref (the repo's own branches, default branch) maps to the `shared` write scope; any other ref (a fork PR) maps to `isolated`, writing to a per-run `iso/<runId>/` scope. This is the cache-isolation model: a fork can restore from the trusted `shared/` cache but can never write into it, so it cannot poison the entries a trusted branch later restores. The org segment of the key namespace (`cache/<orgId>/`) is the per-tenant boundary — no tenant can read another tenant's cache. See [orchestrator storage layout](../operator/orchestrator/storage-layout.md#user-cache) for the full prefix map and quota/TTL knobs.
10362
+
9438
10363
  ## Internal event routing flow
9439
10364
 
9440
10365
  Internal events (custom events from `ctx.emit()` and system events from workflow/job completion) flow through the event router for fan-out delivery to matching workflows.
@@ -9467,11 +10392,11 @@ EventRouter.onNotification(eventId) [private]
9467
10392
  | increments attempts and records claimed_at/claimed_by atomically)
9468
10393
  |-- If lease acquired:
9469
10394
  | |-- processSubscriptions(event):
9470
- | | |-- If RegistrationIndex available (new path):
10395
+ | | |-- If RegistrationIndex available:
9471
10396
  | | | Look up registrations by trigger type
9472
10397
  | | | TrustStore.isTrusted() (for cross-repo events)
9473
10398
  | | | matchAllWorkflows() against registered workflows
9474
- | | |-- Else (legacy fallback):
10399
+ | | |-- Else (no RegistrationIndex):
9475
10400
  | | | TrustStore.isTrusted() (for cross-routing-key events)
9476
10401
  | | | matchAllWorkflows() against in-memory lock file subscriptions
9477
10402
  | | |-- For each match: onEventMatched(event, lockFile, matchedWorkflows)
@@ -9503,7 +10428,7 @@ Two invariants keep events from being silently lost:
9503
10428
  event lands in the DLQ (`dlq_at` set, `dlq_reason='exhausted_retries'`)
9504
10429
  and is surfaced via Prometheus (`kici_orch_event_dlq_*`), Grafana
9505
10430
  (`event-delivery` dashboard), and the kici-admin CLI
9506
- (`kici-admin event-dlq {list,retry,discard}`).
10431
+ (`kici-admin event-dlq {list,count,retry,discard}`).
9507
10432
  - **Crash detection:** when a node crashes mid-dispatch, its lease ages out
9508
10433
  after `leaseDurationMs` (default 60 s). The leader's
9509
10434
  `EventRetryScanner` releases the expired lease and re-publishes
@@ -9939,141 +10864,6 @@ The Platform tier exposes a `/ws/browser` WebSocket endpoint for dashboard clien
9939
10864
 
9940
10865
  ---
9941
10866
 
9942
- ## Design decisions
9943
-
9944
- Source: https://docs.kici.dev/architecture/design-decisions/
9945
-
9946
- This page explains the rationale behind KiCI's major architectural choices. Each section describes the decision, why it was made, and what alternatives were considered.
9947
-
9948
- ## TypeScript over YAML
9949
-
9950
- **Decision:** Workflows are defined in TypeScript, not YAML.
9951
-
9952
- TypeScript provides full language power -- type checking, IDE autocomplete, refactoring tools, conditional logic, and composability. Workflows are regular TypeScript code that can be tested, imported, and reused like any other module. YAML-based CI systems (GitHub Actions, CircleCI) limit expressiveness to what the YAML schema supports, requiring workarounds for anything beyond basic conditionals.
9953
-
9954
- **Alternative:** YAML workflow definitions (industry standard). Rejected because YAML constrains what users can express and makes workflows harder to test and refactor.
9955
-
9956
- ## Lock file approach
9957
-
9958
- **Decision:** The compiler generates a `kici.lock.json` file that separates workflow definitions from runtime execution.
9959
-
9960
- The lock file is a JSON snapshot of workflow definitions with static triggers pre-evaluated at compile time. This enables the orchestrator to match triggers without cloning code -- it fetches only the lock file via the GitHub API. Dynamic elements (dynamic jobs, dynamic matrices, rules) are represented as source references in the lock file and evaluated at runtime by the agent.
9961
-
9962
- **Alternative:** Runtime evaluation of TypeScript at the routing tier. Rejected because it would require the orchestrator to clone repositories for trigger matching, adding latency and complexity to the dispatch path.
9963
-
9964
- > See `packages/compiler/src/` for the compiler that generates lock files.
9965
-
9966
- ## Three-tier relay model
9967
-
9968
- **Decision:** The system is split into three deployment tiers: Platform (webhook router), orchestrator (execution brain), and agent (execution worker).
9969
-
9970
- This architecture separates trust boundaries. Customer code never leaves customer infrastructure -- the Platform tier handles only webhook verification and routing, with no access to customer code or secrets. The orchestrator matches triggers without cloning code. Only the agent, running on customer infrastructure, clones repositories and executes steps. The three-tier model also enables fully self-hosted deployment where all tiers run on customer infrastructure.
9971
-
9972
- **Alternative:** Monolithic Platform that runs everything (customer code on KiCI servers). Rejected because it requires customers to trust a third party with their source code and secrets.
9973
-
9974
- > See [Architecture Overview](overview.md) for the full three-tier diagram.
9975
-
9976
- ## Synchronous webhook relay
9977
-
9978
- **Decision:** The Platform tier relays webhooks synchronously over WebSocket: verify signature, route to orchestrator, wait for ACK, respond to GitHub.
9979
-
9980
- Synchronous relay is simpler, lower latency, and eliminates infrastructure dependencies. GitHub's built-in webhook retry mechanism handles the failure case -- if the Platform tier cannot relay (no orchestrator connected), it returns an error and GitHub retries automatically. This approach removed the need for a separate broker and an async job queue.
9981
-
9982
- **Alternative:** Async queue (store webhook in a queue, process later). Rejected because it added infrastructure complexity (a separate broker and queue layer) without meaningful benefit -- GitHub already provides reliable retry behavior.
9983
-
9984
- ## Per-source webhook secrets
9985
-
9986
- **Decision:** Each webhook source has its own webhook secret stored on the orchestrator side, dynamically registered by orchestrators via the `source.register` protocol message.
9987
-
9988
- Per-source secrets enable multi-tenant Platform with proper isolation. Each customer's webhook source (e.g., a GitHub App) has a unique webhook secret, and signature verification uses the correct secret for each incoming webhook based on the routing key derived from the `X-GitHub-Hook-Installation-Target-ID` header.
9989
-
9990
- **Alternative:** A single shared webhook secret via environment variable. Rejected because it cannot support multiple tenants and provides no isolation between customers.
9991
-
9992
- ## Routing key routing
9993
-
9994
- **Decision:** Webhooks are routed by provider-scoped routing key (e.g., `github:12345`) derived from the `X-GitHub-Hook-Installation-Target-ID` header. One routing key maps to one orchestrator connection.
9995
-
9996
- App ID routing provides a clean 1:1 mapping between a customer's GitHub App and their orchestrator. The orchestrator extracts the `installation_id` from the webhook payload body for GitHub API calls (fetching lock files, posting check runs).
9997
-
9998
- **Alternative:** Route by Installation ID (more granular, one App can have many installations). Rejected because it adds complexity without clear benefit -- most customers use one App with one or a few installations, and the orchestrator can handle multiple installations internally.
9999
-
10000
- ## PostgreSQL as the orchestrator's only data store
10001
-
10002
- **Decision:** The customer-deployable orchestrator depends on a single PostgreSQL database — no separate broker, cache, or pub/sub system. PostgreSQL is the primary data store on the Platform tier as well (the two tiers run independent databases).
10003
-
10004
- PostgreSQL backs the orchestrator's durable job queue (delayed and recurring jobs, retries, dead-letter handling), workflow runs, dispatched jobs, sources, secrets, and event registrations. All DB access goes through a typed SQL query layer. Cross-instance coordination inside an orchestrator cluster (peer announcements, run cancellation, secret invalidation) uses PostgreSQL LISTEN/NOTIFY, so adding orchestrator replicas does not introduce a new infrastructure dependency.
10005
-
10006
- **Alternative:** A separate broker / queue / pub-sub system alongside PostgreSQL. Rejected for the customer-deployable orchestrator because PostgreSQL covers durable jobs, transactional state changes, and cross-instance coordination in a single dependency — adding a broker would multiply operator surface area without buying customers a capability they don't already have.
10007
-
10008
- ## Pure-function state machine
10009
-
10010
- **Decision:** The state machine uses pure functions: `transition(state, event) -> newState` with no internal state, no classes, and no mutation.
10011
-
10012
- Pure functions are trivially testable (input/output, no setup), serializable (state is a string), and usable across all tiers without instantiation. The transition table is a plain `Record` for type-safe dispatch. The state machine tracks workflow runs, jobs, and steps using 11 states and 16 events.
10013
-
10014
- **Alternative:** Class-based state machine with internal state (e.g., `machine.apply(event)`). Rejected because it is harder to test (requires instantiation and lifecycle management), harder to serialize (class instances), and adds unnecessary complexity for a simple state transition problem.
10015
-
10016
- > See `packages/engine/src/state-machine/machine.ts` for the implementation and [State Machine](./execution/state-machine.md) for full documentation.
10017
-
10018
- ## Single agent binary with label-based routing
10019
-
10020
- **Decision:** One agent binary, configured with labels via the `KICI_LABELS` environment variable (e.g., `linux,docker,gpu`). The orchestrator routes jobs by matching `runsOn` labels against registered agent labels.
10021
-
10022
- A single binary simplifies deployment and maintenance. Labels provide flexible routing without requiring separate agent builds or deployments per platform. New capabilities are added by deploying the same binary with different labels and environment configuration.
10023
-
10024
- **Alternative:** Separate agent binaries per platform (e.g., `kici-agent-linux`, `kici-agent-docker`). Rejected because it multiplies build and deployment complexity without meaningful benefit.
10025
-
10026
- > See `packages/agent/src/config.ts` for label configuration.
10027
-
10028
- ## Lock file schema versioning
10029
-
10030
- **Decision:** The lock file includes a `schemaVersion` field that tracks breaking changes to the trigger format.
10031
-
10032
- | Version | Description |
10033
- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
10034
- | 12 | Current schema. Covers everything in v11 plus per-workflow `registries` and `installEnv` fields for private-package authentication. Token bytes never appear in the lock file — only qualified `<environment>:<secret-name>` references that the orchestrator resolves at dispatch time through the per-environment secret resolver. |
10035
- | 11 | Covers the full trigger taxonomy (git events, internal events, schedule, lifecycle, generic webhooks), the `_type` discriminator on each trigger entry, polymorphic `runsOn` matching with `excludeLabels`, hook and concurrency flags on `LockJob`, repo-pattern matching with `!` negation, and `LockInlineValue` for pure-function inline evaluation. |
10036
-
10037
- The `_type` field on each lock file trigger entry identifies the trigger type for the matcher's exhaustive switch dispatch. Users must recompile workflows with `pnpm kici compile` after upgrading to get the latest trigger types in their lock files.
10038
-
10039
- **`SimulatedEvent.type`** is an open `string` type, allowing the `kici test` CLI to simulate any event type without requiring engine changes for each new event.
10040
-
10041
- ## Two-phase evaluation
10042
-
10043
- **Decision:** Trigger matching happens in two phases: fast reject at the orchestrator (no clone needed) and full evaluation at the agent (clone and run TypeScript).
10044
-
10045
- The orchestrator evaluates static triggers from the lock file -- branch patterns, path patterns, and event types. This is fast and requires only a small JSON file fetched via the GitHub API. The agent handles everything that requires code: rule evaluation (arbitrary TypeScript functions), dynamic job generation, and dynamic matrix expansion. This separation keeps the orchestrator fast and git-agnostic.
10046
-
10047
- **Alternative:** Full evaluation at the orchestrator (clone repo, run TypeScript, then dispatch). Rejected because it would make the orchestrator slow, require git access at the routing tier, and blur the trust boundary between routing and execution.
10048
-
10049
- ## Registration model for event-triggered workflows
10050
-
10051
- **Decision:** Workflows with non-Git triggers (schedule, kiciEvent, genericWebhook, workflowComplete, jobComplete, lifecycle) are extracted from the lock file on default-branch push and stored in a registration database. The orchestrator matches incoming events against these registrations without fetching the lock file each time.
10052
-
10053
- **Why:** Git-based triggers (push, PR) work with per-event lock file fetching because the webhook itself provides the repo and ref. Internal events (cron fires, custom events, workflow completions) do not carry repo/ref information -- the orchestrator needs to know which workflows to evaluate before the event arrives. The registration model solves this by pre-populating the orchestrator's knowledge of event-triggered workflows.
10054
-
10055
- **Alternative:** Fetch all lock files from all known repos on every internal event. Rejected because it would be O(repos \* events) API calls, adding unacceptable latency and GitHub API rate limit pressure.
10056
-
10057
- > See `packages/orchestrator/src/registration/` for the implementation.
10058
-
10059
- ## Event trigger matching via registration index
10060
-
10061
- **Decision:** The event router matches events exclusively against the DB-backed `RegistrationIndex`. A previous legacy in-memory subscription fallback was removed since the registration index is always available and is the sole authoritative source for trigger matching.
10062
-
10063
- **Why:** A single code path eliminates drift risk between dual paths, halves the test surface, and avoids confusing future contributors about which path is authoritative. The project's pre-release "no backward compatibility" rule means deferred cleanup shouldn't accumulate.
10064
-
10065
- > See `packages/orchestrator/src/events/event-router.ts` and `packages/orchestrator/src/registration/` for the implementation.
10066
-
10067
- ## See also
10068
-
10069
- - [Architecture Overview](overview.md) -- the three-tier model and package structure
10070
- - [State Machine](./execution/state-machine.md) -- details of the pure-function state machine
10071
- - [Webhook Delivery](./webhooks/webhook-delivery.md) -- synchronous relay in action
10072
- - [Reconnection](./clustering/reconnection.md) -- WebSocket resilience patterns
10073
- - [Event System Internals](./webhooks/event-system.md) -- event router, registration model, cron scheduler
10074
-
10075
- ---
10076
-
10077
10867
  ## Architecture overview
10078
10868
 
10079
10869
  Source: https://docs.kici.dev/architecture/overview/
@@ -10233,14 +11023,17 @@ flowchart TD
10233
11023
  PLATFORM --> SHARED
10234
11024
  ORCH --> ENGINE
10235
11025
  ORCH --> SHARED
11026
+ ORCH -.->|dev| AGENT
10236
11027
  AGENT --> ENGINE
10237
11028
  AGENT --> SDK
10238
11029
  AGENT --> SHARED
11030
+ AGENT --> CORE
10239
11031
  KICI["kici (wrapper)"]
10240
11032
  KICI --> COMPILER
10241
11033
  KICI --> CORE
10242
11034
  KICIADMIN["kici-admin (admin CLI)"]
10243
11035
  KICIADMIN --> ORCH
11036
+ KICIADMIN --> AGENT
10244
11037
  ```
10245
11038
 
10246
11039
  **Leaf packages** (no `@kici` dependencies): `@kici-dev/core` and `@kici-dev/engine`. These can be tested and built independently. `@kici-dev/shared` builds on `@kici-dev/core` and re-exports it. The dashboard depends on `@kici-dev/engine` for shared types (protocol schemas, state machine) and imports the Platform's API type definitions as a dev dependency, but communicates with backend services at runtime via HTTP/WebSocket, not at compile time.
@@ -10277,6 +11070,5 @@ KiCI uses application-level tenant isolation. The Platform dashboard API accepts
10277
11070
  - [State Machine](./execution/state-machine.md) -- execution lifecycle tracking across all tiers
10278
11071
  - [Protocol Messages](protocol-messages.md) -- WebSocket message schemas for all three layers
10279
11072
  - [Webhook Delivery](./webhooks/webhook-delivery.md) -- end-to-end trace of a webhook through all three tiers
10280
- - [Design Decisions](design-decisions.md) -- rationale behind the three-tier model and other architectural choices
10281
11073
 
10282
11074
  ---