Deprecations and compatibility
KiCI keeps its published surfaces working across 0.x releases. When a
surface changes, the old form is deprecated — it keeps working, marked
deprecated, alongside the new form — rather than removed outright. Deprecated
surfaces are removed only at a major version bump; while KiCI is on the
0.x line, that first sweep is v1.0.0.
This page is the running list of everything currently deprecated on a
customer- or operator-facing surface: the SDK, the kici and kici-admin
CLIs, .kici/ configuration, and the customer-facing side of the wire
protocol, lock file, and storage layout.
How to read this table
Section titled “How to read this table”| Column | Meaning |
|---|---|
| Surface | Where the deprecation applies (SDK export, CLI flag, config field, protocol field, …). |
| Deprecated | The old form that still works but is marked deprecated. |
| Replacement | What to migrate to. |
| Deprecated in | The release that introduced the deprecation. |
| Removal | The release the old form is removed in (defaults to v1.0.0 on the 0.x line). |
Deprecated surfaces
Section titled “Deprecated surfaces”| Surface | Deprecated | Replacement | Deprecated in | Removal |
|---|---|---|---|---|
| Wire protocol (provenance) | Platform-rooted identity mint (oidc.mint.* RPC) — the orchestrator relays each build’s identity-token mint to the hosted Platform | Orchestrator-owned signing: the orchestrator holds its own ES256 key, mints + signs identity tokens locally, and publishes its own OIDC discovery + JWKS (configure KICI_ORCHESTRATOR_PROVENANCE_ISSUER). Existing Platform-signed bundles keep verifying forever (the Platform JWKS stays published). | v0.2.0 | v1.0.0 |
| Dashboard-write policy value | Boolean per-operation policy (true = enabled, false = disabled) in the stored policy and on the kici-admin org-settings dashboard-writes set --op <name>=<true|false> flag | Tri-state posture permissive | encrypted | disabled. Booleans are still accepted everywhere (true → permissive, false → disabled) and stored rows are auto-migrated; --op <name>=<permissive|encrypted|disabled> is the new form. No removal until v1.0.0. | v0.2.0 | v1.0.0 |
| Wire protocol (artifacts) | artifacts.upload.complete fields storageKey (the storage key echoed back by the agent) and sizeBytes (the artifact size declared by the agent) | Nothing to change on your side: the orchestrator derives the storage key from the run and artifact name it resolved server-side, and records the real object size it reads back from storage. Both fields are still accepted, and the agent still sends them. | v0.2.0 | v1.0.0 |
| Object-storage layout (user cache) | cache/<orgId>/<repoId>/<scope>/<key>.tar.gz — the cache key was the object-name segment on its own, so on a case-insensitive store two keys differing only by case resolved to one object | cache/<orgId>/<repoId>/<scope>/<key>-<discriminator>.tar.gz, where the discriminator is a hash of the exact cache key. Nothing changes on your side: entries in the old form are still read back on an exact-key restore for one cache-TTL window (default 7 days), and restoreKeys prefix matching is unaffected because the discriminator is a suffix. | v0.2.0 | v1.0.0 |
| HTTP API (webhook test ping) | success in the POST /orgs/:customerId/webhook-endpoints/:endpointId/test response body | delivered — the same boolean under a name that says what it means. Both fields are sent with the same value. A test ping for an endpoint that is not your organization’s now answers 404 instead of 200 {"success": false}, so check the HTTP status too. | v0.2.0 | v1.0.0 |
Held runs (holdType value) | approval for a reviewer hold and wait_timer for a workflow-install wait hold, on the held-runs read surface and in the held_runs.hold_type column | reviewer and timer — the same four-value hold-type vocabulary the protection gates emit (reviewer, timer, concurrency, security). Nothing to change on your side: your orchestrator backfills existing rows on upgrade and keeps accepting both spellings on read, so a held run written before the upgrade still resolves and displays correctly. | v0.2.0 | v1.0.0 |
SDK needs[].when / lock-file runOn | 'on-failure' expanding to failed, timed_out_stale only, so a downstream error handler did not run when its upstream was dropped by determinism drift — even though such a run is already reported as failed | 'on-failure' now expands to failed, timed_out_stale, drift_dropped. The change is additive: an error handler starts firing in a case it should always have covered, and none stops firing. Pass a raw status set (when: ['failed', 'timed_out_stale']) to keep the narrower behaviour. When it takes effect: a static job’s edge is expanded at compile time, so an already-deployed lock file keeps its frozen set until the workflow is recompiled; a dynamicJob’s edges are expanded on the agent at run time, so those pick up the widened set as soon as the agent is upgraded, with no recompile. | v0.2.0 | n/a — a semantic widening, not a removal |
| Wire protocol / job status vocabulary | The terminal job-status set having no member for “the job’s runsOn matched no agent” — such a job was never tracked at all, so a run could report success while a job it declared never ran | A new unroutable terminal job status. A job that stays queued for the whole window with no agent able to run it settles unroutable, its check-run conclusion is failure, the job’s error message names the unsatisfied runsOn, and the run fails. The change is additive: unroutable joins failed, timed_out_stale, drift_dropped in the 'on-failure' expansion, so an error handler starts firing in a case it should always have covered, and none stops firing. Deploy the Platform before the orchestrator — an older Platform rejects the unrecognised status value. | v0.2.0 | n/a — an additive status, not a removal |
kici-admin secret scopes | Listing only the PG backend, with scopes printed unqualified | kici-admin secret scopes <orgId> --all-backends (and GET /api/v1/admin/secrets/scopes?allBackends=true), which aggregates every registered backend and prints scopes in qualified <backend>:<path> form. The bare pg-only output is unchanged until v1.0.0, when --all-backends becomes the default; pass the flag now if your scripts should see every backend. | v0.2.0 | v1.0.0 |
When a surface is deprecated, it is added here in the same change that introduces the replacement, and it appears in the changelog for that release.
Changed error messages and rejected values
Section titled “Changed error messages and rejected values”Some changes cannot go through the table above, because there is no old form to keep working alongside the new one. Error text is one: there is no way to emit an old sentence and a new one at once. A value that is now refused is the other — where accepting it was itself the defect, continuing to accept it for a deprecation window would keep shipping the bug. Both take effect in the release that ships them. The changes worth naming:
| Surface | What changed | Changed in |
|---|---|---|
SDK (ctx.artifacts) | An artifact name that breaks the name contract now fails upload() / download() with a readable sentence — artifact "bad/name": invalid artifact name: artifact name may only contain letters, digits, ".", "_", and "-" — instead of a raw dump of the schema-validation issues. Catching the failure, testing it with instanceof Error, and reading .message are unaffected; matching the schema library’s own error class, or reading its issues array, no longer works. | v0.2.0 |
| Admin HTTP API (secrets) | A routing-key-scoped admin token (kici-admin token create --routing-key <key>) is now refused with 403 on every admin secret route. It previously reached the secret store for the one scope whose name exactly equalled the token’s routing key — listing that scope’s keys, deleting the scope, and deleting a secret in it. There is no dual-accept window, because the same release validates scope names segment by segment against [A-Za-z0-9_.-], which admits no :, while every routing key is <provider>:<id> — so the single scope the grant covered is no longer a name that can be created. The grant was a namespace confusion rather than a usable slice of the secret store: a routing key identifies an inbound source, whereas a secret scope is a path in a backend namespace where : is reserved to qualify the backend. Use an unscoped admin token for secret management; routing-key scoping is unchanged everywhere else (sources, runs, events, registrations, the event log). | v0.2.0 |
kici-admin context (--hold-expiry) | A hold expiry of 0 is now rejected with context: holdExpirySeconds must be >= 1 on context create, context set-policy and context create-template, with HTTP 400 on the matching orchestrator admin routes, and on the dashboard Protection tab. A stored 0 placed each hold’s deadline at the instant it was created, so the hold expired before a reviewer could act and the job it gated was cancelled — accepting the value was the defect, so there is no dual-accept window. Any context already holding 0 is backfilled to no explicit expiry on upgrade, which resolves to the documented one-hour default. To clear an existing hold expiry, pass an empty value to context set-policy (--hold-expiry ""); that previously parsed to 0 and now sends an explicit clear. On context create and context create-template there is nothing to clear — omit the flag, since an empty value there is still read as 0 and rejected. | v0.2.0 |
| Held-run approve / reject (security holds) | Each hold type now takes exactly the permission that matches it, on the held-run approve and reject routes — the dashboard, the kici CLI, and any direct caller of those routes alike. The per-type rules below describe a hold on a run inside the caller’s repository patterns: a repository-scoped caller acting on a hold outside its patterns is refused first, with a 403 that names neither the hold’s type nor a required permission, so that refusal reads the same whatever kind of hold it was. A security hold requires ci_trust:write; contexts:write alone is refused with 403 and requiredPermission: "ci_trust:write". Symmetrically, reviewer and concurrency holds require contexts:write, so a principal holding only ci_trust:write no longer releases them and is refused with requiredPermission: "contexts:write". (A timer hold requires contexts:admin — see the wait-timer row below.) Both routes previously accepted either permission for every hold type, and the distinction existed only in the dashboard’s button gating — so anything speaking to the API directly released security holds with the broader grant. Accepting that was the defect, and a dual-accept window would keep the privilege separation unenforced, so there is no such window. These routes can also now answer 502 when the hold’s type cannot be resolved: the check fails closed rather than guessing. Migration: grant ci_trust:write to the roles that release security holds, contexts:write to any role that releases reviewer or concurrency holds, and contexts:admin to any role that releases wait-timer holds — or, for a member who already holds one of those two permissions, set a per-member CI trust override on the Members tab. An override adjusts an existing approver’s trust level on every surface, dashboard included; it does not by itself let a member reach these routes. An org API key uses its own permission matrix and does not inherit its owner’s override. | v0.2.0 |
| Held-run approve / reject (wait-timer holds) | Approving, rejecting, or skipping a timer hold now requires contexts:admin. It previously accepted contexts:write over the API and the kici CLI, while the dashboard already required contexts:admin to skip a wait and the documentation promised it — so the stricter rule existed only client-side and anything speaking to the API directly skipped a wait with the broader grant. The server now enforces contexts:admin on every surface; a caller holding only contexts:write is refused with 403 and requiredPermission: "contexts:admin". Accepting the weaker grant was the defect, so there is no dual-accept window. The legacy persisted spelling wait_timer normalizes to timer and takes the same rule. Migration: grant contexts:admin to the roles that skip wait timers. | v0.2.0 |
Developer MCP (approve_run / reject_run) | Both tools now require the hold’s own permission in addition to runs:write. They previously checked only runs:write, so an agent credential released a security hold without ci_trust:write — a wider grant than the one the held-run routes had already been tightened to require. Each tool now applies the same policy the Platform’s approve/reject routes apply: ci_trust:write for a security hold, contexts:admin for a timer hold, contexts:write otherwise; runs:write is still required as well. A call that fails the narrower check returns Insufficient permission: <resource>.<level> needed to act on this hold and never relays the decision. Accepting runs:write alone was the defect, so there is no dual-accept window. Migration: grant an agent credential the permission matching the hold classes it is meant to release. | v0.2.0 |
| Secret key names | A secret key is now validated against [A-Za-z0-9._-] (maximum 256 characters) on every write plane — kici-admin secret set, PUT /api/v1/admin/secrets/:orgId/:scope/:key, the dashboard’s add-secret form, and the .kici/secrets.yaml / .kici/.secrets seeding kici run local performs (which stops at the offending key, leaving the secrets already seeded in place — rename it and re-run) — and a key outside that set is refused with Secret key may only contain letters, digits, and _ . - characters (HTTP 400 on the admin route). Each secret’s value is encrypted with its <orgId>:<scope>:<key> location bound in as authenticated data, so a : inside the key made that binding ambiguous: a key of c:d in scope b produced the same binding as a key of d in scope b:c, and a value written at one of those locations would decrypt at the other. Accepting the character was the defect, so there is no dual-accept window. Migration: none, and nothing already stored becomes unreachable. Validation applies to writes only — reading, listing and deleting still accept any key — so a secret stored under a non-conforming key before the upgrade keeps resolving in workflows and can still be deleted. To rename one, write the value under a conforming key and delete the old one. | v0.2.0 |
| Secret scope names (stored form) | A scope name is now validated segment by segment against [A-Za-z0-9_.-], which admits no :. The <backend>: qualifier is no longer part of the stored name: every write plane strips it and routes the operation to that backend, so pg:production is stored as production inside the PG backend. Accepting : in a stored name was the defect — it made a scope reachable under one spelling and invisible under the other — so there is no dual-accept window. Migration: an orchestrator that predates this stored scopes with the qualifier attached, and those rows are unreachable after the upgrade. Run kici-admin secret fix-prefixed-scopes <orgId> --database-url "$KICI_DATABASE_URL" once per organization to rename them to their bare paths; the scope name is bound into each secret’s authenticated encryption, so the command re-encrypts every value as it renames and a direct SQL rename would not work. Preview with --dry-run. It repairs only the pg: qualifier and never merges two scopes: a scope whose bare target already exists, or that carries another backend’s qualifier, is reported SKIPPED and the command exits 2. See Secrets management. | v0.2.0 |
| Secret scope rename (dashboard + admin API) | Renaming a secret scope now refuses two inputs it previously accepted. A rename that crosses backends (pg:a → vault:b, or any pair whose qualifiers resolve to different backends) is refused with 400 and Cannot rename a scope across backends; it previously renamed inside the source backend while reporting success, so the operator was told the secrets had moved when they had not. A rename onto a scope that already exists — one holding secrets, an empty-scope placeholder, or context bindings — is refused with 409 and Secret scope '<name>' already exists; it previously merged the two scopes, re-encrypting every non-colliding key under the destination name with no way back. The dashboard surfaces both as the operation’s error text. Accepting either input was the defect, so there is no dual-accept window. Migration: to move a scope between backends, create the secrets in the destination backend and delete the source scope — a rename cannot do it, because the value’s authenticated encryption binds the scope name and only the source store can re-encrypt. To combine two scopes, set the keys explicitly in the target. | v0.2.0 |
| Role repository scope (grant ceiling) | A member can no longer grant a repository scope wider than the one they hold. Creating a role, updating a role, assigning a role to a member, granting a role to a team, and inviting a member into a role are each refused with 403 when the requested repoPatterns are not contained by the caller’s own — including the case where repoPatterns is omitted on create, since the field defaults to *. Containment is deliberately conservative: a pattern is grantable only when the caller’s scope is *, when the caller holds that exact pattern, or when the request names one concrete repository the caller’s patterns already reach — so a genuinely narrower glob such as myorg/backend-* requested by a caller holding myorg/* is also refused, and the workaround is to grant the literal pattern. Separately, POST /orgs/:customerId/api-keys is refused with 403 for a repository-restricted caller, because an organization API key carries no repository scope and operates across every repository within its permission level. Widening a scope you do not hold was itself the escalation, so there is no dual-accept window. Migration: an organization owner (scope *) performs the grant, or widens the delegating member’s own scope first. The break-glass kici-platform-admin role create is unaffected. | v0.2.0 |