Ports
A port is a trait a step asks for and never implements (ADR 0002): ks-core declares the traits in crates/core/src/ports/, an adapter crate outside this workspace is the only code that knows how to answer one for a particular vendor, and HarnessBuilder::build hands each step only the ports it declared. The module's own documentation carries the inventory of which ports exist today — this page does not repeat it.
What this page records is the contract version. ks_core::STEP_API versions the port traits alongside the StepKind trait (ADR 0014), and the harness refuses to compose a step built against a different one. A fingerprint of the port trait signatures is pinned against STEP_API in crates/testing/tests/compatibility.rs, so changing a trait without bumping the version fails CI rather than landing as a silent break. The generated compatibility table is COMPATIBILITY.md.
Adding a port, adding a method to one, or changing a signature is a contract change: bump STEP_API in crates/core/src/version.rs, add a ## STEP_API entry below, update the pinned digest in the compatibility test, and regenerate COMPATIBILITY.md with KS_BLESS=1 cargo test -p ks-testing --test compatibility. Reformatting and comment edits do not need any of that — the fingerprint normalises them away.
Port reference
One row per port trait, verified against crates/core/src/ports/. In Port is the question that matters to a step author, and it is checked here against the Port enum and the define_ports! list in crates/core/src/step.rs, which must agree in order: a step can only declare a port that has a variant. The kit is the port's conformance function in ks-testing — most are named conformance, but the two supply-chain ones are registry_conformance and signer_conformance, and a few take extra arguments beyond the trait (cluster::conformance a name and a manifest, iac::conformance a directory). — means the module ships fakes but no kit. The implementor is the in-tree impl of the trait.
Ports a step can declare
| Trait | For | In Port | Kit | Implementor |
|---|---|---|---|---|
RunContext | who and where a run is | yes | run_context::conformance | ks-cli's GithubActionsRunContext |
Secrets | resolving named secrets, redacting values | yes | secrets::conformance | core's ActorGatedSecrets, the engine's PolicySecrets |
ImageBuilder | building a container image | yes | — | — |
Registry | pushing and pulling by digest | yes | supply_chain::registry_conformance | ks-oci-distribution |
Signer | signing, attestations, digests | yes | supply_chain::signer_conformance | — |
IacTool | planning and applying infrastructure | yes | iac::conformance | ks-tofu-cli |
HttpClient | bounded, policy-checked HTTP | yes | http::conformance | ks-cli's TcpHttpClient |
Clock | time and sleeping | yes | clock::conformance | ks-cli's SystemClock |
IdGen | minting fresh run ids | yes | id_gen::conformance | — |
ScriptHost | running TypeScript escape-hatch steps | yes | script_host::conformance | ks-deno-script-host |
ApprovalChannel | asking a team for approval | yes | approval::conformance, which takes the channel, a Directory, a team and an approver, and so covers Directory too | ks-hosted-approvals, ks-github |
Cluster | applying manifests and watching rollouts | yes | cluster::conformance, which takes a name and a manifest | ks-helm-cli |
RemoteShell | commands on a host the harness does not own | yes | remote_shell::conformance | — |
FunctionHost | publishing a version, shifting traffic | yes | function_host::conformance | — |
DecisionModel | asking a model a judgement it cannot be handed | yes | decision_model::conformance | ks-jev |
Engine- and CLI-only ports
No Port variant, so no step can hold one and STEP_API does not move when one changes. The Implementor column says which crate ships the one that runs in production.
| Trait | For | Implementor | Kit |
|---|---|---|---|
BlocksIndex | the metadata-only blocks index | CLI's HostedBlocksIndex | — |
BlockSource | block content, resolved and fetched by digest | — | block_source::conformance |
PolicyStore | the org baseline policy, read from outside the repository | — | policy::conformance |
RunLog | the append-only record of what a run did | CLI's JsonlRunLog | — |
StateStore | a parked run's state and its saved plans | engine's LocalStateStore | state_store::conformance |
ApprovalInbox | the approver's side: what is waiting for me, and here is my answer | — | approval::inbox_conformance, which takes a channel and an inbox |
health is not a port: crates/core/src/ports/health.rs holds only the Health / HealthReport vocabulary the deploy-target ports report in.
Writing an adapter? STEP-AUTHORING.md has the crate shape and the test-file pattern; changing one of the traits above? The changelog below is where it goes.
Changelog
Newest first, one heading per contract version, dated.
STEP_API 6
2026-10-08 —
RunLock(#175) added, incrates/core/src/ports/run_lock.rs: mutual exclusion of runs that change one (account, module), as a lease —acquire,renewandreleaseover aLeasecarrying an expiry and a fencing token strictly increasing per key across every grant, so a run whose workflow resumed after its lease expired and was taken over acts with a lower token and is refused by whatever the lease guards. Re-acquire by the live holder is idempotent (the Worker restarted mid-run and the workflow resumes); a lapsed lease goes to the next taker at a higher token. LikeBlockSource,PolicyStoreandApprovalInbox, it is engine/CLI-only — the engine holds it across plan-through-apply, a step never sees it — so it is in neitherPortnordefine_ports!, and no step-facing surface changed. The fingerprint covers every trait inports/, so the digest is re-pinned underSTEP_API 6without a bump.
2026-10-08 —
Eventgainedprovenance(#126), with aProvenanceenum beside it incrates/core/src/ports/run_context.rs: where the code a run is about came from, as distinct fromEstablishment, which says who is running it.Repository,ForkandTarget(pull_request_target,workflow_runandmerge_group— the events a privileged workflow follows untrusted code on) — the last two answeris_untrusted(). A push to the default branch and a fork pull request are both aCiactor with an OIDC identity from GitHub's OIDC endpoint (which the adapter does not verify the signature of), and they are not the same amount of trust: the second runs aship.kssomebody outside the organisation wrote, so a policy that gated on identity alone would hand it repository secrets and a cloud credential. The default isRepository, but the only value that grants those capabilities is the one an adapter has to claim affirmatively, so a missing value fails closed. This is source-breaking for anyone constructing anEventliterally, so it is recorded here rather than landed as a silent change;Eventis data a step reads rather than a trait it implements, so no trait signature moved and the fingerprint — which coverspub traitblocks inports/— is unchanged, and it stays pinned underSTEP_API 6rather than forcing a bump for a field the gate cannot see.
2026-10-08 —
ApprovalInbox(#95) added, incrates/core/src/ports/approval_inbox.rs: the approver's end of an approval, next toApprovalChannel— what is waiting for me (pending) and here is my answer (decide). The inbox builds theVerdictitself, bound to the request's own artifacts, and checks it withApprovalRequest::acceptsbefore handing it over, so a proposer clicking approve on their own run fails at the click; a run with nothing open against it is the newApprovalError::NoOpenRequest, whichApprovalErrorgained here. LikeBlockSourceandPolicyStorebelow, it is engine/CLI-only — the CLI holds the approver's side, so a run's author cannot reach it — and it is in neitherPortnordefine_ports!, so no step-facing surface changed andSTEP_APIdoes not move. The fingerprint covers every trait inports/, so the digest is re-pinned underSTEP_API 6without a bump.
2026-10-07 —
ScriptHost::runtakes a fourth argument,log: &StepLog(#91): a script's log frames now go to the step's own redacting logger, so they pass through the run'sRedactorexactly as a built-in step's lines do, and a script that logs a secret it revealed prints***. This closes the log side of the existing contract — secrets already reached scripts over a dedicated file descriptor, never argv, the environment or stdio — so an adapter no longer has to get redaction right on its own or not at all. The change is breaking for everyScriptHostimplementor, which is whySTEP_APImoves.The other half is in the engine rather than the port: every step's outputs go through
reject_secret_outputs, which fails the step (InvalidOutput) if a value the run resolved appears anywhere in them, including inside a longer string. Returning a secret is refused rather than redacted, because an output feeds wiring, cache keys and the run log, where a masked value helps nobody. Nothing about which ports exist changed, soPortanddefine_ports!are untouched.
STEP_API 5
2026-10-07 — Additive: the
RemoteShellport gainedconnect(#76), opening a shell to a second pinned host over the same transport and credentials as the adapter's configured host. Eachconnectchecks the caller-supplied [HostKey]; an unknown or mismatched host isShellError::Unreachable/ [ShellError::HostKeyMismatch], never accepted on trust (no TOFU). No existing method changed shape;run,copy,probeandhostare as declared underSTEP_API 4. The fingerprint covers every trait inports/, so the digest is re-pinned underSTEP_API 5.
STEP_API 4
2026-10-07 — Breaking: the
Clusterport'supgradeno longer takes a release name (#74). A name cannot express "install this chart at this digest with these values", so the signature is nowfn upgrade(&self, request: &ChartUpgrade), and aChartUpgradecarries the whole request: aChart— anImageRefby digest for an OCI chart, or a directory or packaged.tgzfor aPath— an optionalnamespace, aValuesdocument, a--setmap, and an optionalImagePinthat writes the container image's digest into the chart's values at a configurable key (image.digestby default).Valuescarries the security contract, which is why it is a type rather than aString: values built withValues::secretMUST be handed to the chart tooling by a temporary file with0600permissions, deleted once the call returns, and MUST NOT appear on a command line where another process could read them. Asetentry is passed as a--setargument, so it must not carry a secret.upgradeis also now explicitly install-or-upgrade — an adapter installs a release the cluster does not have — and must roll back rather than report failure when an upgrade would leave the cluster worse than it found it.Breaking for adapters: every
impl Clustermust be recompiled and itsupgradeupdated to read the request offChartUpgrade.
2026-10-07 — Breaking: the "digest, never a tag" promise above is now enforced rather than documented.
ImagePin::newreturnsResult<Self, ClusterError>(it was infallible) and both it andImagePin::atreject a tag with the newClusterError::NotPinned { what };Chart::oci(..) -> Result<Chart, ClusterError>does the same for an OCI chart.Valuesno longer derivesDebug— its hand-written one redacts the document, so anassert_eq!on aChartUpgradecannot print a secret.
STEP_API 3
2026-10-08 — Additive: the
Portenum gained aDecisionModelvariant (#104), so thedecidestep can declare and receive the port its trait has had sinceSTEP_API 2.DecisionModelitself is untouched — the sameaskandprofilesignatures, and the sameRedactedState— so this is thePort/define_ports!half of the contract only, and the fakes inks-testingwork against it unchanged. The variant is optional for a step:decidewithmodel: nonedeclares it optional and never reaches for it, and a step that names a model with no adapter wired fails rather than defaulting.
STEP_API 2
2026-10-07 —
BlockSourceandPolicyStore(#56) added, both engine/CLI-only:BlockSourceis how an online command resolves a block reference and fetches its content by digest, andPolicyStoreis how the org baseline policy is read from outside the repository (ADR 0012). Neither is inPortnor indefine_ports!, socheckstays offline and no step can hold either, andSTEP_APIdoes not move. The fingerprint covers every trait inports/, so the digest is re-pinned underSTEP_API 2without a bump.
2026-10-07 — Additive: the
StateStoreport joined the contract (#42), carrying a parked run's state and its saved plans.StateStorehas two methods,putandget, both scoped to aRunIdand a key the port validates (check_key), so no key can address anything outside its own run directory. Nothing existing changed shape, and no step sees the port: the engine uses the store directly, soPortanddefine_ports!are untouched. The remaining ports are as declared above.
2026-10-07 — Added the
DecisionModeltrait (#54), incrates/core/src/ports/decision_model.rs. Oneaskcall carries oneRedactedStateand aBTreeMap<String, Question>, and answers withBTreeMap<String, Answer>— so the model sees one picture and the answers are consistent with each other.DecisionModelalso declaresprofile(), returning aModelProfilenaming the model, its pinned version, its calibration family and itsmax_state_bytestruncation bound.Like
BlocksIndexbefore it, this is a trait plus fakes and a conformance kit: there is noPortenum variant yet, so a step cannot declare it and a harness cannot hand one to a step. The trait is versioned now so that adding the variant later is not itself a contract change.
2026-10-07 — Added the
ApprovalChannelandDirectoryports (#53), so a run can ask a team and the engine can tell who was on it.ApprovalChannelrequests, polls and waits;Directoryanswers membership. No existing trait signature changed — the ten ports fromSTEP_API 1are untouched, andBlocksIndexstill is. The newPortvariantsApprovalChannelandDirectoryare the only additions to the enum.The rule they encode: an approval is bound to the exact artifacts it was asked about, and a run can never approve itself.
ApprovalRequest::acceptsrefuses an approver who proposed the run, an approver who is not human, and a verdict whosebound_artifactsdiffer by one digest — enforced in the engine, not in each adapter, so the same rule holds for every channel. Membership is checked separately, asynchronously, throughDirectory— and it is the verdict's ownapproverthat gets looked up, never the approver the caller hoped for. The adapter conformance kit inks-testingchecks exactly that: an adapter that answers with somebody else's verdict, or binds a verdict to different artifacts, fails the kit rather than shipping.
2026-10-07 — The deploy-target ports (#52, #78). Four modules land: the
Clusterport (apply,upgrade,rollout,health,rollback,handle), theRemoteShellport (run,copy,probe,host), theFunctionHostport (publish,shift,health,keep_warm,rollback,pinned,pin_is_detectable), and the sharedHealth/HealthReportvocabulary they all report in. The three traits added to the port contract areCluster,RemoteShellandFunctionHost;healthadds no trait, only the value types every health call returns.
STEP_API 1
2026-10-07 — The initial port contract (ADR 0014). The port traits as declared in
crates/core/src/ports/at this version areRunContext,Secrets,ImageBuilder,Registry,Signer,IacTool,HttpClient,Clock,IdGen,ScriptHostandBlocksIndex.STEP_APImoved fromcrates/core/src/step.rstocrates/core/src/version.rs, which is now the single home for the three public contract versions;ks_core::step::STEP_APIstill resolves. Any later change to a port trait adds a## STEP_API 2heading above this one and bumps the constant.2026-10-07 —
RunLog(#46) added, engine-only: steps never receive it, so it is not inPortnor indefine_ports!, and it does not moveSTEP_API. The fingerprint covers every trait inports/, so the digest is re-pinned here without a bump.