Extending

Adapters, verbs, prompts, guardrails, middleware, packs and standards: every seam the framework leaves open, and what each one is for.

Extension is ordinary subclassing plus binding. There is no plugin manifest and no registration DSL, because the container is already the registration mechanism.

Every snippet on this page assumes two names and imports everything else: lockstep, the Lockstep your module built, and ctx, the RunContext a workflow is handed. The suite type-checks each page's snippets, in order, under the same mypy --strict an adopter runs, with exactly those two provided — so a shape shown here is a shape the checker accepts.

A different adapter

A verb is an interface; anything satisfying it can serve it.

import subprocess

from in_lockstep import Capability, Outcome, RunContext, Status, Test, Verb
from in_lockstep.core.types import TestReport

class ToxTest:
    verb = Verb.TEST
    capabilities = frozenset({Capability.EXECUTES_CODE, Capability.READS_REPO})

    async def invoke(self, ctx: RunContext, request: Test) -> Outcome[TestReport]:
        ran = subprocess.run(["tox", "-q"], cwd=ctx.repo.root, capture_output=True, text=True)
        status = Status.SUCCEEDED if ran.returncode == 0 else Status.FAILED
        # A real adapter parses the report; the shape is what matters here.
        return Outcome(status=status, value=TestReport())

lockstep.bind(Test, ToxTest())

Test is the request type. Workflows do ctx.do(Test(...)), and the request's type is what the binding serves, so the same Test names both what a workflow asks for and what your adapter receives.

An adapter that runs a tool can also say where it found it, by implementing locations(root) and returning Resolution values (in_lockstep.core.types). That is what puts the indented line under a binding in ls and lets doctor refuse before a run when the tool is nowhere. The shipped adapters resolve the repository's own .venv first; yours can use in_lockstep.adapters.tooling to do the same.

Provision is the verb that builds the environment those resolutions look in first, and the scaffolded work jobs run in-lockstep provision before doctor. Detection binds it only from a lockfile that exists, through that lockfile's own tool: uv sync --locked, poetry install, pdm sync, pipenv sync, npm ci, yarn install --frozen-lockfile, pnpm install --frozen-lockfile, bundle install, composer install, mix deps.get or swift package resolve, and dotnet restore from the project file alone, because the SDK guarantees it. A layout it does not read is one line in the module, lockstep.bind(Provision, CommandProvision([["nix", "develop"]])). CommandProvision runs its steps in order and stops at the first that fails. It is the one shipped adapter whose sandbox allows the network, because reaching a registry is its job; it still drops every credential, because a lockfile's install hooks are repository-authored code. The shipped binding installs a lockfile's default groups and nothing more; a layout that keeps its test dependencies in an extra binds CommandProvision([["uv", "sync", "--locked", "--all-extras"]]).

capabilities is the part people skip and should not. Policy keys off it without knowing anything about your adapter: whether egress enforcement is mandatory, whether an approval gate applies, whether retry may re-invoke it. Declaring EXECUTES_CODE is how the framework knows to run you out of process, away from anything holding credentials.

REACHES_NETWORK exists separately from WRITES_FILES. "Read-only" describes mutation, not transmission. A fetch tool mutates nothing and is still an egress channel.

The shape every single-turn adapter shares, gather, compose, one turn, settle, check, is drawn in single-turn verbs; the wider map is the runtime architecture.

A verb of your own

The shipped verbs are not a closed set. A verb is a name: a routing key and a telemetry label. Declaring one is constructing it, and the request it serves is an ordinary frozen dataclass:

from dataclasses import dataclass

from in_lockstep import Capability, Outcome, RunContext, Status, Verb

BENCHMARK = Verb("benchmark")

@dataclass(frozen=True)
class Benchmark:
    """The Benchmark request. Workflows do `ctx.do(Benchmark(...))`; a binding decides what runs it."""
    iterations: int = 100

class PyperfBenchmark:
    verb = BENCHMARK
    capabilities = frozenset({Capability.EXECUTES_CODE})

    async def invoke(self, ctx: RunContext, request: Benchmark) -> Outcome[dict[str, float]]:
        return Outcome(status=Status.SUCCEEDED, value={"seconds": 0.0})

lockstep.bind(Benchmark, PyperfBenchmark())
outcome = await ctx.do(Benchmark(iterations=1000))

Verb used to be a closed enum, which made binding a new interface possible and mislabelled: the adapter had to borrow a shipped member, so a benchmark reported itself as run in every span, metric dimension and step id, and shared a strategy namespace with something unrelated. Now the span says benchmark.

Verbs are interned and case-normalised, so Verb("test") is Verb.TEST and identity comparisons keep working. The consequence worth knowing: a typo produces a distinct verb rather than silently aliasing an existing one. in-lockstep ls prints any verb that is defined and unbound, which is the shape that mistake takes. Nothing is printed when there are none.

Nothing about a custom verb is second-class. Middleware sees it, Spend charges against it, capabilities_for reads its adapter, and the kill switch stops it. What it does not get is a shipped strategy or model route, because those are keyed by verb and the framework has none for a verb it has never heard of. Declare them yourself, or pass the model explicitly.

A house prompt

Prompts are classes; their bodies are markdown files.

from in_lockstep.prompts.review import SecurityReviewPrompt

class OurSecurityReview(SecurityReviewPrompt):
    version = "team-3"
    emphasis = "SQLAlchemy 2.x session discipline; no bare excepts"

Subclassing and setting emphasis keeps the shipped body and adds to it. To replace the prose entirely, point at your own file:

from in_lockstep.ai.prompt import Body
from in_lockstep.prompts.review import ReviewPrompt

class OurReview(ReviewPrompt):
    version = "team-1"
    body = Body.from_path("prompts/our-review.md")

Body.from_path is a file in this repository, read as given. Body.from_file is a resource inside a package, for a body a pack ships beside its classes, and it needs package=: without one it refuses naming from_path, rather than look inside the framework. A house body lives in prompts/ at the repository root. Not under .lockstep/: that directory is deny-always for every writing verb, so a body there can be reviewed with but never proposed to by the learning loop, and improve --explain says so. doctor checks that every bound body resolves, and show-prompt refuses in one line when one does not.

Writing one is half the job. Installing it goes through bind, like every other extension. A prompt is not a separate kind of thing with a separate registration mechanism:

from in_lockstep.adapters.ai import AiReview, Review
from in_lockstep.prompts.review import LENSES

lockstep.bind(
    Review,
    AiReview(lenses={**LENSES, "security": OurSecurityReview}),
)

Spreading LENSES keeps the three lenses you did not override. Passing a bare dict replaces the map entirely, which is what you want when your team ships its own set of aspects. An unknown aspect then reports the lenses this adapter has, not the ones that happen to ship.

The map is copied at construction, in both directions: a later mutation of LENSES cannot reach an adapter you already bound, and an adapter cannot leak a lens back into the shipped map.

Enhancing a shipped lens, without a subclass

An entry in that map is either a prompt class or a Lens: the declared form, carrying the prompt and everything about running it that used to belong to the adapter alone.

from in_lockstep.adapters.ai import AiReview, Review
from in_lockstep.prompts.review import LENSES, Lens, SecurityReviewPrompt, review_layers

lockstep.bind(
    Review,
    AiReview(
        lenses={
            **LENSES,
            "security": Lens(
                prompt=SecurityReviewPrompt,
                emphasis="SQLAlchemy 2.x session discipline; no bare excepts",
                layers=review_layers().plus(guardrails=(("acme/house", "Never touch migrations."),)),
                max_tokens=8000,
            ),
        },
    ),
)
lockstep.models.route("review/security", "anthropic:claude-opus-4-6")

Every field but prompt defaults to the adapter's, so Lens(SecurityReviewPrompt) means exactly what the bare class meant. What each one adds:

  • **emphasis** rides after the shipped body under its emphasis heading, beside anything a subclass's own emphasis says. The shipped prose is kept; this is for the three sentences a team wants to add to security without a class. A pack offers the same through pack.emphasis("style"), which reads prompts/style.md with its header stripped.
  • **layers** is this lens's whole stack, and the other lenses keep the adapter's. Whole, not appended to: spell it review_layers().plus(...) to keep the baseline, and ls prints one guardrail chain per distinct stack so a lens that dropped it is visible beside the ones that did not.
  • **max_turns may only tighten and max_tokens** replaces, and the asymmetry has a reason on each side. The adapter's turn cap is already the lower of its own and the policy floor an organisation contributed, and the floor cannot be recovered from that number, so a lens asking for more turns is clipped rather than guessed at. No such floor exists for output tokens, so a lens's number is exact, and it is the number review.truncated tells you to raise for one lens.
  • The route is a line in the models table rather than a field: review/security wins over review, a lens with no route of its own takes the verb's, and ls flags a route to a lens nothing binds the way it flags a route to a verb nothing serves.

The key stays security, and that is the point of the shape. review.security is the finding id, the sticky comment's marker, the Improvable label and the census key, all at once. An enhancement published under a new name would fork every one of those for every consumer. **Namespace on plus, a name nobody had; never on replacing, a shipped name.** A lens of your own is a11y; a better security is still security.

A lens is not a verb

> A Lens differs in what it says. A Verb differs in what it may do.

The first instinct is to make each review kind a verb of its own -- REVIEW_SECURITY, REVIEW_TESTS -- so each can carry its own prompt and route. Dispatch is keyed on the request type, not the verb, so four verbs are mechanically four request dataclasses, four adapters and four binds, and the same again for every lens an extender adds; and review.<aspect> re-keyed would split every ledger record and every Improvable written against the old key. What it would buy is one span name.

Capabilities are declared on the adapter, and approval and budget gates read them off the bound object. Four prose lenses over one diff share one posture -- a single turn, the diff in the prompt, no tools -- and are lenses. A review that must execute the suite, hold write tools, or take a deployed artifact instead of a diff has crossed a capability line, and that one genuinely is a verb: declare it as A verb of your own above describes.

Read what you bound, before it runs:

in-lockstep show-prompt security          # what a run would send, from the bound adapter
in-lockstep show-prompt security --diff   # what you changed, against the shipped body
in-lockstep show-prompt security --shipped

ls prints the same thing in summary: every prompt each AI binding composes, starred where it is not the shipped one, with the guardrail chain underneath so a stack that does not open with the framework's baseline is visible without a second command.

The body stays a .md deliberately. The people who write review prompts are frequently not Python programmers, and prompt text in a string literal has escaping hazards that prose in a file does not. It is also a security property: a prompt change proposed by the improvement loop is data rather than executable code entering your module's import graph.

Bumping version matters for a different reason than it looks. Eval identity is a content hash of the composed prompt, not the declared version, so a measurement is correct whether or not you remember to bump it. version is the human label that travels alongside.

Where a house prompt lands among the shipped layers and a standards package, and what the composed text then keys, is drawn in prompt composition.

House guardrails

Every AI adapter composes its prompt from layers: guardrails first, then the body, then skills. Every one takes the stack as layers=, the same seam prompts=/lenses= are. To add your own "do not" rules to a verb, extend the shipped stack and hand it to the adapter:

from pathlib import Path

from in_lockstep.prompts.implement import implement_layers

house = implement_layers().plus(
    guardrails=(("acme/house", Path("prompts/house-guardrails.md").read_text()),),
)
from in_lockstep.adapters.ai import Implement, Oneshot

lockstep.bind(Implement, Oneshot(layers=house))

plus appends. Your guardrail lands after the framework's baseline and ahead of the body, so extending the stack cannot quietly drop the shipped constraints, and the position (guardrails before everything) stays the security property the composer guarantees. Replacing the stack wholesale is constructing a fresh PromptLayers, which is the visible, greppable spelling of that decision.

emphasis on a prompt subclass still exists and is the right place for style guidance. A guardrail is for constraints, and the difference is where it sits: emphasis rides after the body, guardrails before it.

House skills and contexts

The two layers after the body take the same road. A skill is how the model should go about a kind of work; a context is what it should know about this repository. Both are named text, both are appended through plus, and both land after the body and its emphasis: skills first, then contexts, in the order given.

from pathlib import Path

from in_lockstep.prompts.implement import implement_layers

knowing = implement_layers().plus(
    skills=(("acme/migrations", Path("prompts/skills/migrations.md").read_text()),),
    contexts=(("acme/architecture", Path("prompts/context/architecture.md").read_text()),),
)
from in_lockstep.adapters.ai import Implement, Oneshot

lockstep.bind(Implement, Oneshot(layers=knowing))

in-lockstep show-prompt implement/oneshot --projection prints the stack with each layer named in its position, skill:acme/migrations before context:acme/architecture, both after the body. A skill body is part of the eval subject a run records, so editing one moves the subject the way editing a prompt body does.

The learning loop

in-lockstep improve is the framework reading its own record and proposing a change to one of its prompts, as a pull request a person reads. It runs in three refusals, two drafting-and-measuring model calls and one judge ask per rubric per arm, and the refusals come first.

A trend has to qualify. The census improve --explain prints — a finding id that recurs across enough billed runs and enough weeks — and a body has to claim it. Bodies are declared, never guessed from a finding id's shape:

from in_lockstep.core.improve import Improvable

lockstep.improve = (
    Improvable(body="prompts/review/security.md", verb="review",
               label="review/security", answers=("review.security",)),
)

The body has to be writable by grant. prompts/ is tier 2, so the proposing workflow needs a named grant, and nothing names this path is a refusal rather than a permission:

from in_lockstep.core.changes import ChangeGuard, PathPolicy

lockstep.guard = ChangeGuard(PathPolicy(
    grants=frozenset({"prompts/"}), granted_to_workflow="improve/propose",
))

A promoted case has to fail against the body as it stands. A harvested case passes the answer it was harvested with by construction, so the loop can only learn from a case somebody tightened — see evidence/README.md. On a corpus at its ceiling it refuses before spending.

Then it drafts, on the model routed for improve, and re-asks every attributable case against the draft on the model that case was recorded on. The scorecard has both arms over the same cases; improved means a case the current body failed now passes and none was lost, and only improved is staged. in-lockstep run improve/propose opens it, counting what is already open on the host first, and then parks on that pull request's review (ctx.park(HumanBoundary.pr_review(...))) when the bound LedgerStore is shared; improve/after-review is the continuation a person's verdict starts, through in-lockstep resume --run <id> --as approved --by <login> or the resume.yml dispatch. On a local store the proposal is opened and nothing parks, and the run says so. Bind the adapter and the corpus, register the process, and it runs the same way at a terminal and from .github/workflows/improve.yml:

from in_lockstep.adapters.ai import AiImprove, Draft, Measure
from in_lockstep.core.improve import Improver
from in_lockstep.improver import CorpusImprover
from in_lockstep.workflows import improve

improving = AiImprove()
lockstep.bind(Draft, improving)
lockstep.bind(Measure, improving)
lockstep.bind(Improver, CorpusImprover("evidence/cases"))
lockstep.models.route("improve", "anthropic:claude-opus-4-6")
improve.register()

A rubric expectation is put to the bound judge on both arms, one ask per case per arm, as one step of the same run -- so the judge's calls share the measurement's budget, its reconciliation and its tape. The judge is a verb of its own, judge, routed apart from the drafter because a grading model is a choice of its own; this repository routes it at Haiku, and the free local path is one line away. A verdict is kept beside the case in a .verdicts.jsonl sidecar keyed by the rubric's and the answer's content hashes, so a rubric judged once over one answer is replayed rather than paid for again, and an answer that changed by a byte is judged afresh. A rubric the judge did not answer, or one no judge is bound for, stays outstanding on both arms and the body says so rather than printing a pass; the person on the pull request is the judge of last resort.

in-lockstep eval run --judge --corpus evidence/cases --budget 0.10 judges the promoted corpus on its recorded answers, as a recorded run under a ceiling. Plain eval run never spends.

from in_lockstep.adapters.ai import AiJudge, Judge

lockstep.bind(Judge, AiJudge())
lockstep.models.route("judge", "anthropic:claude-haiku-4-5")  # or a free local model

A harvested case records the model it was answered by as <registration>:<model>: the registration name the run routed to, and the bare id that registration was sent. The after arm re-asks the case through the same registration, so the name has to be there. It comes from the registry, which stamps it on every provider it builds and which a recording keeps beside the request; harvest never guesses a provider from a model's name. A case that names no provider, one harvested from a tape recorded outside the registry, is refused before the drafter is paid, and the refusal says which case and what to set. A repository on its own registry hands it to the adapter as AiImprove(registry=...), the way it hands one to invoker_factory.

The loop end to end, from a recording to a measured proposal, is drawn in the learning loop; the two workflows and the judge are improve and judge, and the park on the proposal's review is human boundaries.

Middleware

Cross-cutting behaviour (tracing, budgets, retries, approval) is a middleware chain around every ctx.do. There is no before/after registration API, because next is an explicit parameter and that gives you before, after, around and instead from one hook:

from datetime import date

from in_lockstep.core.middleware import ActionCall, Next, capabilities_for
from in_lockstep.core.outcome import Outcome
from in_lockstep.core.verbs import Capability

def _is_friday() -> bool:
    return date.today().weekday() == 4

class FridayFreeze:
    async def __call__(self, ctx: object, call: ActionCall, next: Next) -> Outcome[object]:
        if _is_friday() and Capability.WRITES_FILES in capabilities_for(ctx, call):
            return Outcome.blocked_by("policy.friday_freeze")
        return await next()          # <- no arguments

lockstep.middleware += [FridayFreeze()]

Two things in there are easy to get wrong, and both fail quietly rather than loudly.

**next() takes no arguments.** The context and the call are already closed over by compose, so the obvious guess, await next(ctx, call), raises TypeError. Returning without awaiting it is instead; awaiting it and inspecting the Outcome is after; wrapping it in a try is around.

**Capabilities come from capabilities_for(ctx, call), not from the call.** An ActionCall names an interface; capabilities belong to whatever is bound to serve it, which is the entire point of binding. capabilities_of(call) type-checks, returns an empty set, and therefore fails open: your gate silently permits everything. That is the one mistake in this section worth memorising.

Order is outermost-first: middleware[0] sees the call before middleware[1] and sees the Outcome after it. Each layer is one ordinary frame in a traceback, because the chain is folded by plain closures rather than decorators, so a pdb breakpoint lands where you expect.

Two constraints worth knowing before you write one.

**Middleware runs once per ctx.do, not once per model turn.** A long agentic loop is a single ActionCall, so a ceiling you enforce here is checked before the loop starts and after it ends, and never in between. That is why the spend check and the deadline live inside AiInvoker, re-evaluated every turn, rather than being middleware. If what you are writing needs to interrupt a loop in progress, middleware is the wrong layer.

Some actions must not be re-invoked. An action declaring Capability.SPENDS_BUDGET re-runs a whole agentic loop and re-pays every turn already spent, so anything that might call next() twice should check Capability.SPENDS_BUDGET in capabilities_for(ctx, call) first and refuse. The framework ships no retrying middleware for exactly this reason: retry belongs at the transport, where one HTTP attempt is one HTTP attempt, and AiInvoker carries that layer.

What you cannot do here is redaction, egress or residency. Those are privileged: they run outside this chain because --no-middleware exists, and a debugging flag must not be able to switch off the thing keeping credentials out of a committed record.

Fan-out, and a run that waits on a person

A workflow runs several actions at once with ctx.fan_out, and the join is one JoinResult: its status is the worst branch's, its cost the sum, and it is decided only if every branch was. The branches share one budget, one tape and one kill switch, so four lenses cost one ceiling rather than four. review/all-lenses, this repository's required check, is one fan-out over every lens the bound adapter declares (drawn).

from typing import Any

from in_lockstep import Outcome, RunContext, workflow
from in_lockstep.adapters.ai import Review


@workflow(id="review/two-lenses")
async def two_lenses(ctx: RunContext, base: str, head: str) -> Outcome[Any]:
    join = await ctx.fan_out(
        security=ctx.call(Review(base=base, head=head, aspect="security")),
        tests=ctx.call(Review(base=base, head=head, aspect="tests")),
    )
    return join.as_outcome()

A run can also end at a human boundary and continue when the person acts. ctx.park(HumanBoundary.pr_review(41), resume="improve/after-review") writes a barrier record into the shared store, ends the run PARKED (exit 4, with the resume command printed), and labels the pull request lockstep:parked. in-lockstep ls --parked lists what is waiting; in-lockstep resume --run <id> --as approved --by <login> applies the event and starts the continuation, which receives a Resumption by annotation: who acted, what they said, and the barrier's view of every branch (drawn). A park needs a store other machines can see, which is one line:

from pathlib import Path

from in_lockstep.core.ports import LedgerStore
from in_lockstep.platform.ledger import GitLedger

lockstep.bind(LedgerStore, GitLedger(root=Path(lockstep.repo.root), shared=True))

On the default local store ctx.park returns BLOCKED naming the store rather than waiting for an answer no other machine could give.

Packs: an extension that travels

Everything above is an extension you wrote in your own repository. A pack is the same thing packaged so another repository can install it: an ordinary Python distribution declaring an in_lockstep.extensions entry point.

[project.entry-points."in_lockstep.extensions"]
acme-review-prompts = "acme_review_prompts"

Installing a pack offers it. It does not apply it. That is the one difference from in_lockstep.standards above, and it is deliberate. A standards package may only tighten, so applying it automatically is safe and forgetting it is the real risk. An extension hands a model write and execute tools and pays for a model call.

Nothing is in force until a line in your lockstep.py says so, and that file loads from a trusted ref, which is what keeps which strategy runs from becoming a string a ticket body could eventually reach.

in-lockstep pack ls                              # offered, not in force
in-lockstep pack describe acme-review-prompts    # what it holds, before you trust it
in-lockstep add acme-review-prompts              # accept it, and print the lines to paste

add re-derives the receipt from the code that is installed, compares it with what this repository accepted before, records the result at .lockstep/packs/<name>.json, and prints the lines. Commit that record; it is the acknowledgement.

add does not write lockstep.py and it does not install anything: putting a stranger's code on your machine belongs in your dependency diff.

A capability the pack did not previously hold is refused until --accept says so, because more agency is the change that should cost a decision. doctor re-derives against the record afterwards: DOC170 fails on widened capabilities, DOC171 warns when a bound prompt no longer opens with the shipped baseline, DOC172 warns when a pack is installed unpinned.

from in_lockstep.adapters.ai import AiReview, Review
from in_lockstep.packs import pack
from in_lockstep.prompts.review import LENSES, SecurityReviewPrompt, review_layers

acme = pack("acme-review-prompts")

class OurSecurity(SecurityReviewPrompt):
    version = "acme-1"
    body = acme.body("prompts/security.md")

lockstep.bind(
    Review,
    AiReview(
        lenses={**LENSES, "security": OurSecurity},
        layers=review_layers().plus(guardrails=acme.guardrails("house")),
    ),
)

A pack's guardrail is labelled <pack>/<name> in the projection, because a projection is read to answer "whose rule is this" and two packs contributing house would otherwise be indistinguishable in the one artifact meant to tell them apart.

Finding one: catalogs

A catalog is a static index.toml in a git repository. No service, no accounts, no ranking.

in-lockstep market add acme https://raw.githubusercontent.com/acme/index/main/index.toml
in-lockstep search tdd

search groups by source, because the difference matters. The project's catalog states entry criteria and an organisation's internal tap states none. A pack published inside your company is trusted by that fact, which is a different question and a better answer. A name two catalogs claim is reported rather than resolved.

Registering a source writes .lockstep/market.toml, committed, because a catalog decides where this repository looks for code. https only: a catalog says what to install, so it is fetched over a channel that cannot be rewritten in transit.

An entry points at a receipt derived by pack describe and committed beside the index, so it records what the author's code did rather than what the author wrote. add re-derives the same receipt locally and refuses a pack that holds more than the catalog published. That refusal is not behind --accept, because it is not a decision to weigh. It is a listing that does not describe the code you installed.

Publishing one is committing the file. in-lockstep market lint index.toml checks each entry against the criteria the catalog claims to apply, which is what keeps a criterion from being a sentence in a README. See [examples/lockstep-index/](../examples/lockstep-index/).

What pack describe tells you before you install

Every field is read off something that already declares it, so a listing is a computation rather than an author's prose. Two are worth reading first.

imports says what installing puts in your import graph. It reads none when every .py the pack ships holds a docstring and nothing else, modules when there is real code, and unknown when the distribution could not be resolved to files, which is not the same as inert. That none is derived by walking the AST, not promised by the pack's kind.

pack ls and pack describe never import a pack to answer this; describe imports afterwards, and only when it has already reported there is something to import.

guardrails_intact says whether the prompts it offers still open with the framework's baseline. Replacing the stack wholesale is legal (see House guardrails above), and this is where it becomes visible.

pack.toml carries a kind and a summary, and nothing else is accepted. An unknown key is refused rather than ignored: the moment that file can carry a binding, a policy or a model route, it has become the alternate configuration surface this framework does not have. The kind is also cross-checked against what describe derived, so a pack that calls itself prose and ships a strategy says so in its own receipt.

[examples/acme-review-prompts/](../examples/acme-review-prompts/) is the worked example, and the cheapest kind: markdown, a corpus that measures it, and one __init__.py holding a docstring.

Measuring one before you trust it

Everything else about a pack can be checked; none of it says whether the pack is any good. The honest answer to that is a measurement you make on your own cases.

in-lockstep pack try acme-review-prompts --corpus ./our-cases

It replays the pack's cassette (no key, no spend), runs its corpus and yours, and counts them apart, because the number worth installing on is the one measured on your cases.

Read the states, not only the rate:

State Means
decideda machine settled it; only these feed the pass rate
outstandinga rubric, and no judge has answered it
unrecordedthe cassette holds no answer for this case: an absence of evidence, never a failure
unexerciseda corpus family a trial cannot drive yet (it drives review)

When nothing was decided there is no pass rate, and the output says which absence that is rather than printing a zero.

Somebody has to pay once. A trial replays what was recorded, so a pack with no cassette cannot be measured for nothing. That is what the project catalog's fourth criterion is about, and why pack try says so rather than reporting an empty result.

The author records it with in-lockstep pack try <pack> --record, once, against a real model, and commits the cassette into the pack. Recording transmits, so it is subject to the same egress rules as any other real call; replaying transmits nothing, and the invoker knows it.

The trial composes the pack's prompts/<aspect>.md inside the shipped layer stack, paired with corpus/review/<aspect>-reviewer/. Your own guardrails are deliberately not applied: measuring a pack through your configuration would measure your configuration, and two repositories would get different numbers for the same pack with no way to tell why.

The four bands and the order they are applied in are drawn in extension resolution.

Organisation standards

Bindings resolve repository-above-organisation, which is right for adapters and wrong for standards. Standards go on the policy stack instead:

from in_lockstep import Policy

lockstep.contribute(Policy(name="acme-floor", max_turns=8, deny_tools=("run_script",), scan_input="block"))

Contributions append and only tighten: ceilings take the lowest of several rather than the last read, tool denies union, the strictest scan wins. There is no removal API.

Three fields reach a run, and the rest are printed. max_turns, deny_tools and scan_input are what InvokePolicy.under() composes into the loop. A denied tool is removed from the ToolSet rather than refused when called, and scan_input="block" refuses before the first model call.

Policy carries those three fields and nothing else. It used to carry network, permissions and three credit fields as well; they were merged, printed by ls and reported in the receipt, and enforced by nothing, so #263 deleted them. A security field that reads as in force while enforcing nothing is worse than its absence, and the receipt is the artefact a reviewer trusts.

What to write instead, for each of them:

was use
Policy(network=...)IN_LOCKSTEP_EGRESS=enforced under a host that constrains destinations, verified by a probe, with UnsandboxedEgress as the named opt-out. Enforced, and checked by doctor.
Policy(permissions=...)the Sandbox a deterministic adapter runs under, and deny_tools for what a model may call.
the three credit fieldsBudget and the CostBudget middleware, which refuse a run rather than describing one.

Each of those enforces. That is the whole difference, and it is why the fields went rather than gaining an enforcement path of their own: every one of them already had a control covering the same ground, so wiring them would have meant two writers of one rule.

At one repository, that line lives in lockstep.py. At two hundred, a line every repository has to remember is drift by another name. So standards also travel as an installable package: an in_lockstep.standards entry point whose function receives a facade that can contribute layers (stamped with the plugin's source) and bind at Tier.PLUGIN (the repository's own binds still win).

Lockstep.detect() applies every installed one, in entry-point-name order, before your module's own lines run, and in-lockstep ls prints what applied. The worked example is [examples/acme-standards/](../examples/acme-standards/): a pyproject.toml and one function is the entire org layer.

Be clear about what that buys: visibility of removal, not impossibility. A repository can delete the line that contributes your standard, and a middleware chain cannot bound code that never calls ctx.do. Enforcement that must survive a hostile repository owner lives in a required CI check (in-lockstep doctor --strict) and in provider billing limits, not in a library.

Models and providers

A verb is routed to a model, and the route is one visible line. in-lockstep ls prints it:

from in_lockstep import Verb

lockstep.models.route(Verb.TRIAGE,    "local:qwen3-8b")           # cheap reading, on a laptop
lockstep.models.route(Verb.IMPLEMENT, "anthropic:claude-opus-4-6")
lockstep.models.route(Verb.REVIEW,    "anthropic:claude-sonnet-4-6")

A model id is provider:model. The shipped providers are anthropic, local (Ollama), bedrock, vertex (Claude on GCP) and gemini.

Bedrock, Vertex and Gemini authenticate through their cloud's own credential chain (the AWS chain, GCP application-default credentials), so they need no *_API_KEY. Region and project come from the cloud's environment (AWS_REGION; GOOGLE_CLOUD_PROJECT with GOOGLE_CLOUD_LOCATION). Their SDK is an optional extra, imported only when you route to one (in-lockstep[bedrock], [google]), so a repository that routes to none pays nothing.

A cloud provider's model id is the cloud's, not the Anthropic API's, and the two namespaces do not overlap: Bedrock names Claude us.anthropic.claude-sonnet-4-6-v1:0 (or your site's inference profile), Vertex uses an @version suffix.

Because pricing keys on the id, a route to one is unpriced until you say what it costs. doctor refuses an unpriced route before the run spends anything, which is where you find out:

from in_lockstep import Verb
from in_lockstep.ai.pricing import CostTable, Rate, default_table

lockstep.models.route(Verb.REVIEW, "bedrock:us.anthropic.claude-sonnet-4-6-v1:0")
costs = default_table()
costs.add("us.anthropic.claude-sonnet-4-6-v1:0", Rate(3.0, 15.0))   # per million tokens
lockstep.bind(CostTable, costs)

To run against your own gateway, or to state a residency policy in code rather than infer it from an environment variable, build the default registry, register into it, and hand it to the factory:

from in_lockstep.adapters.ai import AiReview, Review
from in_lockstep.ai.bootstrap import default_registry, invoker_factory
from in_lockstep.llm.interface import DataPolicy, ProviderSettings
from in_lockstep.llm.providers.openai_compat import OpenAIProvider

registry = default_registry()
registry.register(
    "house",
    lambda settings, creds: OpenAIProvider(settings, creds),
    settings=ProviderSettings(base_url="https://llm.internal.acme"),
    data_policy=DataPolicy.INTERNAL,          # the operator's claim, greppable, not an env var
    endpoint="https://llm.internal.acme",     # residency keys on where the bytes go
)
lockstep.bind(Review, AiReview(invoker_factory("house:acme-7b", registry=registry)))

in-lockstep doctor reads the routes and warns before a run spends anything if one names a provider nothing registered or a model nothing prices. The failure happens where it costs nothing, not at the first call.

endpoint is compared, at the first use of the provider, with the base URL the constructed client reports it will dial, and a mismatch is refused naming both. That is why the shipped anthropic registration refuses when ANTHROPIC_BASE_URL points anywhere but api.anthropic.com: the variable is read once, in the framework, and handed to the client, so a proxy set through the environment cannot be dialled under a declaration that says otherwise. To run through a proxy or a gateway, register it as above, with its address as the endpoint and its residency as the policy. Bedrock and Vertex derive their endpoint from the region; without one the registration carries endpoint=None and a reason, the manifest names the route it could not list, and a restricted repository refuses it by that reason. A registration of your own may do the same, and only that: endpoint=None needs endpoint_reason, and an empty endpoint is refused.

A registration also declares what its models can do, and two of those declarations are checked before a call is made. Every shipped AI verb asks for its answer in a schema, and the implementing verbs hand the model tools; a registration with caps=ModelCaps(structured_output=False) or tool_use=False is refused by name for the call that needs the capability, before the first turn is paid for, and doctor warns about a route to the first. Both default to capable, so a registration that says nothing is not refused: False is a statement you make on purpose about a model you know.

from in_lockstep.llm.registry import ModelCaps

registry.register(
    "tiny",
    lambda settings, creds: OpenAIProvider(settings, creds),
    settings=ProviderSettings(base_url="http://localhost:8080"),
    data_policy=DataPolicy.INTERNAL,
    endpoint="http://localhost:8080",
    caps=ModelCaps(tool_use=False, structured_output=False),   # refused by name, not charged twice
)

Your provider is still recorded. The framework cannot reach inside your factory, but it holds the invoker your factory returns, and it wraps the provider on that — so an adapter bound this way keeps what it pays for without you doing anything, and O4's every model call is recorded means every. Recording is what a run does; --no-record is how a run declines it. The one shape past that boundary is an adapter that takes no factory at all and constructs AiInvoker inside its own invoke: nothing can wrap that, and rather than report a reassuring zero the run compares what the tape kept against what it spent and tells you a model was called that the recorder never saw.

A strategy

The strategy IS the adapter. A binding does not choose a dispatcher configured by a string; it names the approach itself:

from in_lockstep import Workshop
from in_lockstep.adapters.ai import TDD
from in_lockstep.adapters.sandbox import Sandbox

IMAGE = "ghcr.io/acme/ci:py312"
lockstep.workshop = Workshop(commands=Sandbox(image=IMAGE, require_container=True))
tdd = lockstep.use(TDD)             # or Oneshot, or your own class

use binds the strategy under the request type it serves and finishes constructing it from the module: the resolved policy floor, the repo root, and the workshop's runner wrapped in a WorktreeRunner. It completes what was left unset and overrides nothing, so lockstep.use(TDD(policy=InvokePolicy(max_turns=8))) keeps the policy you named.

bind remains the primitive and the long spelling still works:

from in_lockstep.adapters.worktree import WorktreeRunner
from in_lockstep.ai.invoker import InvokePolicy

sandbox = Sandbox(image=IMAGE, require_container=True)
lockstep.bind(
    Implement,
    TDD(commands=WorktreeRunner(sandbox, lockstep.repo.root), policy=InvokePolicy(max_turns=40)),
)

Prefer use anyway. The two arguments it fills in are the two a hand-written bind can silently drop. Without InvokePolicy.under(policy.resolve(), ...) the contributed policy floor is ignored, and without the WorktreeRunner wrap the container bind-mounts your live tree. Neither omission appears in ls.

Writing one is subclassing AiStrategy (which carries the constructor and the per-run session assembly) and implementing invoke(ctx, request). It imports from the package root:

from in_lockstep.adapters.ai import AGENCY, AiStrategy

Declare id, which lands on the report so an eval subject and a ledger record can key on the approach that ran. Subclass the per-verb base rather than AiStrategy itself: ImplementStrategy and FixStrategy carry verb, request (the key lockstep.use binds under) and capabilities, so a strategy of yours states only what is its own.

from typing import Any

from in_lockstep import Outcome, RunContext
from in_lockstep.adapters.ai import Implement, ImplementStrategy

class Careful(ImplementStrategy):
    id = "implement/careful"

    async def invoke(self, ctx: RunContext, request: Implement) -> Outcome[Any]:
        return Outcome.errored("not written yet")
careful = lockstep.use(Careful)

A subclass of bare AiStrategy has to declare all three itself, request: ClassVar[Any] included, or use refuses it for want of a key to bind under.

capabilities is the load-bearing frozenset every gate reads off the bound object, and it is not optional: subclassing AiStrategy means being handed write_file, edit_file, delete_file and run_script and paying for a model call, so declaring less than AGENCY is refused at class creation. ApprovalGate, the budget refusal and the egress trigger all key on that set. An undeclared strategy would be an ungated one, which is why this is an error and not a warning.

Declaring more is allowed, and is sometimes right: a set that could execute on some other configuration must not read as harmless on this one.

Ship fixtures with a new strategy: ten unmeasured strategies are worse than one measured.

Which strategy runs is a bind-time code decision in lockstep.py, reviewed like any other line. There is no request-time selection and no registry id, so nothing a ticket carries (a label, a comment, a body) can steer a run toward an approach that holds a path grant. What used to be a registry refusal (a privileged strategy unreachable from untrusted input) is now structural.

Binding can also happen at the call, when the workflow should say what serves a request right at the execution site:

from typing import Any

from in_lockstep import Outcome, RunContext, TicketSource

async def implement_with_tdd(ctx: RunContext, ticket: str, tickets: TicketSource) -> Outcome[Any]:
    return await ctx.do(Implement(ticket=await tickets.get(ticket)), via=tdd)

via= is call-scoped: it never touches the container, so nothing leaks into later calls, and the same capability-keyed middleware gates the supplied adapter exactly as it would a bound one. It is still code choosing, because lockstep.py loads from a trusted ref.

It is an override for a verb the module binds, not a replacement: the startup refusals (UngatedAgency, the budget checks) scan bound adapters, so keep the binding and pass the same instance.

Oneshot

The scaffold's default: one session, one model, a tool set that can read, search, stage writes and run a command. (TDD also ships, a test-first loop described below, but oneshot is the cheap default.)

in-lockstep implement --ticket '#42' --approve --budget 2.00 --out .lockstep/change
in-lockstep apply-inline --from-artifact .lockstep/change

Two commands, not one, and deliberately: the session stages writes into a ChangeSet and touches nothing. Applying it is a separate step that runs the same path guard a second time. On CI those are two jobs, and only the second holds a write token.

Three things have to be true before it will start, and each is a control keyed off the adapter's capability declaration rather than anything the strategy configures:

It refuses with Because What to do
ApprovalGateUngatedAgencyA model that can write and spend needs a human in the loop.Add ApprovalGate() to your middleware, or pass --approve for an attended local run.
egress.unenforcedThe tool set declares EXECUTES_CODE, which makes egress enforcement mandatory.Run under a host that constrains egress with IN_LOCKSTEP_EGRESS=enforced, or lockstep.bind(EgressPolicy, UnsandboxedEgress()).
UndeclaredBudgetSomething bound spends money and no ceiling was declared. A replay cannot spend, so --offline and --dry-run state a ceiling of zero for you.lockstep.budget = Budget(usd=2.00), or --budget.
sandbox.host_fallbackThe bound Test runner would run a file the model staged as a subprocess on this host. The repository's own suite may run that way; a model's test may not.Bind Test with Sandbox(image=..., require_container=True), where the image carries the suite's dependencies -- see the TDD section below for the mounts= shape.

The egress one is the surprise on a laptop, and the opt-out is a binding rather than a flag on purpose: UnsandboxedEgress is named after what it does, so it greps and it reviews.

**run_script executes; it does not shell.** Commands arrive as an argv array, with no pipes, no globs and no &&, and argv[0] must be in an allowlist (ALLOWED_COMMANDS).

They run through whatever CommandRunner you supply. --execute supplies Sandbox wrapped in WorktreeRunner, which drops every credential from the child environment, prefers a container with no network, and runs the command in a throwaway worktree of HEAD rather than the live tree. --no-execute withholds the runner while leaving the tool declared, so what policy sees does not change with the flag.

The worktree is not just an accident of hygiene; it is the control. Sandbox bind-mounts its working directory read-write, so without it a command from an allowlisted program (python, make, node) could write .git/hooks or .lockstep/lockstep.py on the real repository. That is the write path ChangeGuard governs for write_file but cannot see once a process is running.

Running in a discarded copy means those writes land nowhere that a later run will read. One consequence, shared with TDD: a linked worktree's .git is a gitlink outside a container's mount, so a command that needs git will not resolve it inside a container. pytest, ruff and mypy do not care.

One thing to know before reading a session's transcript: the working tree run_script runs against does not contain that session's staged writes. It is HEAD in a copy. It tells the model what the existing behaviour is, not whether its change works. Verifying a change is what the apply half is for, and TDD (above) is what runs the suite against the staged change directly.

--max-turns defaults to 40. That is a runaway backstop, not the budget: every turn re-sends the accumulated history, so the thing that actually stops a long session is the per-turn spend check, which refuses before the call that would cross the ceiling.

**delegate is opt-in, and a child inherits every bound.** lockstep.use(Oneshot(delegation=True)) hands the session one more tool: hand a single self-contained task to a nested session over a subset of its own tools, named per call, and get the child's final text back as a tool result. A name the session does not hold is refused by name, the child never holds delegate, and it runs through the same provider, the same Spend and the same transcript as its parent -- so a recording provider records it, a child turn that would cross the run's ceiling is refused inside the child, and its turn cap and deadline are whatever the parent had left. Its answer takes the same redaction and injection scan every tool result does. Off by default because a session that can start sessions is one whose turn cap no longer bounds its model calls on its own; the budget still does.

TDD

Test-first, enforced by the strategy rather than requested in a prompt. It runs in two model steps with a real Test run between them: it asks for a failing test, materialises that test in a throwaway worktree and runs the suite to confirm it is red, then asks for the implementation and runs the suite again to confirm green.

A test that passes before anything was written stops the run with tdd.not_red. An implementation that leaves the test failing comes back tdd.not_green rather than opening a pull request that does not work.

A green suite has two causes and they are reported separately. If the staged files collected no tests at all, the reason is tdd.test_not_collected rather than tdd.not_red, and the finding names the files and points at python_files, python_classes, python_functions and testpaths.

The distinction is worth a second pytest run: a suite that stayed green because it never executed the new test looks exactly like one where the test passed, and a model told its test passed will rewrite the assertions instead of the class name. Both of this repository's own early /implement runs failed this way.

The writing verbs read the repository's AGENTS.md, CLAUDE.md and .cursorrules into their system prompt, after the framework guardrails. review deliberately does not: it runs over a pull_request checkout, where those files are contributor-authored.

This is where the run_script caveat above stops applying: oneshot's run_script sees the tree as it was, but tdd's verdict comes from ctx.do(Test(root=…)) against the materialised change, so it reflects the code as proposed. Because it needs to run the suite, TDD requires a Test verb bound and refuses up front (tdd.no_test) if none is. It will not degrade to an untested oneshot.

Bind it with lockstep.bind(Implement, TDD(...)) and in-lockstep ls prints Implement -> TDD, so how implementing happens is one visible line. The CLI's --strategy tdd does the same for a repository that has bound nothing.

The test it runs is one the model wrote, on a ticket nobody vetted, so the Test runner it hands that file to has to be a container: --network=none, the throwaway worktree as the only writable mount, no HOME. A Test bound with the default Sandbox() -- a credential-dropped subprocess on this host, which is right for in-lockstep run selfcheck over your own committed suite -- is refused before the worktree is made, as sandbox.host_fallback, by TDD, DiagnoseThenFix, the model's run_tests tool and the workflow's verdict over a staged change alike. The refusal names the line to write.

The image is yours to name, because it has to carry the suite's dependencies and no stack image does: the worktree is a copy of HEAD, so .venv and node_modules are not in it. Two shapes. A CI image that already has everything installed:

from in_lockstep.adapters import PytestTest, Test
from in_lockstep.adapters.sandbox import Sandbox

lockstep.bind(
    Test,
    PytestTest(args=["-q"], sandbox=Sandbox(image="ghcr.io/acme/ci:py312", require_container=True)),
)

Or a base image with the environment the host already built mounted read-only beside the tree, which is what this repository does for itself. It works wherever the packages in that environment import inside the container: always on a linux runner, and on a laptop for as long as they are pure Python; a compiled dependency built for macOS fails at import there, naming the module.

import sys

from in_lockstep.adapters import PytestTest, Test
from in_lockstep.adapters.sandbox import Sandbox

PY = f"{sys.version_info.major}.{sys.version_info.minor}"
lockstep.bind(
    Test,
    PytestTest(
        args=["-q"],
        sandbox=Sandbox(
            image=f"docker.io/library/python:{PY}-slim",
            mounts=((f"{lockstep.repo.root}/.venv", "/venv"),),
            extra_env={"PYTHONPATH": f"/venv/lib/python{PY}/site-packages"},
        ),
    ),
)

require_container is off in the second shape on purpose: with it, run selfcheck would refuse on a laptop with no runtime, and that run is a person's, not a model's. The model-staged callers probe for the runtime themselves and refuse when it is missing.

Both shipped strategies are drawn side by side in implement strategies; the fix strategy's reproduce-then-repair shape is in fix.

Reading the process you are running

Extending something starts with reading it, and the shipped processes are framework code rather than lines in your module. show-workflow prints them:

in-lockstep show-workflow                    # the families and their ids
in-lockstep show-workflow implement          # a whole module
in-lockstep show-workflow implement/propose  # one process
in-lockstep show-workflow --registered       # what THIS repository has in force

It reads nothing and spends nothing. The source comes from inspect.getsource on the module that is actually imported, so what you read is what runs — a property a copy in your own file could not have offered, which is the argument that retired init --eject. Ejecting gave you a readable copy and, with it, a fork: every later fix landed twice and reached nobody who had already scaffolded.

--registered answers the neighbouring question. show-workflow implement prints what the framework ships; --registered prints what your lockstep.py put in force, which differs the moment somebody writes their own — and that is exactly the case where reading the shipped source would mislead.

The path a project takes

The framework is built around one arc, and each stage is meant to reuse the last rather than replace it.

Young: a terminal. One or two people, no CI to speak of. Processes are @workflow functions in .lockstep/lockstep.py and you run them by hand:

in-lockstep run implement/from-ticket --arg ticket='#59' --approve --budget 2.00

--approve says you are the human watching. That is a real grant and a weak one, and the ledger records it as attended so it is never confused with a stronger one.

Growing: hosted triggers. More people, and the work should start itself. Nothing about the process changes: an event fires, and CI runs the same command.

- run: in-lockstep run implement/from-ticket --arg ticket="#${ISSUE}" --approved-by "${ACTOR}"

--approved-by replaces --approve because nobody is watching, so the name is the grant and has to be supplied. Both land on RunContext.approval, and ApprovalGate reads it from there, which is what makes this a re-trigger rather than a rewrite.

If the two were plumbed differently, moving to CI would mean reimplementing the process in YAML, which is the failure this arc exists to avoid.

What the CI file adds is only what CI owns: the trigger, the job split, per-job permissions, and which secret each job holds. One process cannot hold two token scopes, so the split has to live there.

Mature: your own verbs and strategies. Verb is an open, interned value type, so Verb("benchmark") is a first-class verb with its own telemetry label, step ids and strategy namespace. Bind an interface to an adapter, register strategies for it, and in-lockstep ls prints the result. See A verb of your own and A strategy above.

Where this is honest about its limits. The shapes are host-agnostic. Scm (platform/scm), TicketSource (platform/tickets) and LedgerStore (core/ports/) are protocols, and nothing in core knows what GitHub is. LedgerStore also declares compare_and_set and a scope: the shipped GitLedger is LOCAL by default and SHARED with shared=True, where the swap is a --force-with-lease push against refs/lockstep/state/<key> on the remote, so the remote is the coordinator and a park or a resume from any machine reads the same record.

Both hosts now have implementations: GitHubScm/GitHubIssues and GitLabScm/GitLabIssues ship, and hosted_scm()/hosted_tickets() in platform/hosted.py pick the detected host's pair so a scaffold module runs unedited on either.

What GitLab still lacks is the comment trigger. GitLab CI cannot fire a pipeline from an issue comment the way issue_comment does on GitHub, so the write-capable flow there starts from a run-pipeline-with-variables instead. See [docs/trampoline.md](trampoline.md).

in-lockstep gate takes --association as an opaque string for the same reason it always did: it works against whatever a host calls its access levels. GitLab computes no author_association, so there the gate answers from CODEOWNERS alone.

Firing it from CI

The rule is one line long: **process goes in .lockstep/lockstep.py, CI invokes it.**

For the processes the framework ships, "goes in" means registered, not written:

from in_lockstep.workflows import implement

implement.register()
in-lockstep run implement/from-ticket --arg ticket='#59' --budget 2.00

That is what init --implement writes, and implement/from-ticket is framework code — read it with in-lockstep show-workflow implement. Registering rather than copying is why a fix to the process reaches a repository by upgrading. **Do not hand-write @workflow(id="implement/...") beside that call**: @workflow refuses a repeated id, so a module claiming a shipped id from a def of its own raises DuplicateWorkflow at load, which is the correct answer to asking for two different things under one name.

A process of your own is the same shape, under an id you own:

from typing import Any

from in_lockstep import Outcome, RunContext, TicketSource, workflow
from in_lockstep.adapters.ai import Implement

@workflow(id="implement/from-label")
async def implement_from_label(ctx: RunContext, label: str, tickets: TicketSource) -> Outcome[Any]:
    ready = await tickets.search(f"label:{label}", limit=1)
    return await ctx.do(Implement(ticket=ready[0]))
in-lockstep run implement/from-label --arg label='ready' --budget 2.00

The signature is the contract: label arrives from --arg label=..., and tickets is filled from the container because its annotation names a bound port. The dispatcher resolves TicketSource so the body never touches ctx.container.

--arg name=value is repeatable and values arrive as strings, which usefully bounds what a CLI-runnable workflow can take: one needing a list of tickets takes a label or a path, not the tickets. Every dispatched run leaves a ledger record carrying its arguments, so "which issue, which actor" survives the run.

What belongs in the CI file is the trigger, the job split, per-job permissions, and which secret each job gets. Those are the CI system's to grant and no Python can express them: one process cannot hold two different token scopes, and keeping a provider key out of the job that can write is the reason the trampoline has two jobs.

Everything else is process, and process in YAML is process with no tests.

.github/workflows/implement.yml is the worked example. A test enforces a budget on how many lines of shell it may contain, and fails if it starts running git commit, gh pr create or gh issue comment itself. Each of those has a port behind it (Scm.open_change, TicketSource.comment), and reaching for the command instead is how lifecycle logic gets back in.

Firing it from a comment

.github/workflows/implement.yml runs a session when an authorized person comments /implement on an issue, using that issue as the ticket. Three jobs, and the shape is the point:

gate      →  implement            →  propose
(no key)     (provider key,          (write token,
              contents: read)         no provider key)

An issue_comment event runs the workflow on the default branch, never a contributor's. That is the same provenance property .lockstep/lockstep.py relies on. So the comment selects a command; it cannot supply one.

Anyone who can see a repository can comment on it, so the comment is not the authorization. That is in-lockstep gate:

in-lockstep gate --actor "$LOGIN" --association "$ASSOCIATION" --codeowners .github/CODEOWNERS

Exit 0 or 3. It passes an org MEMBER/OWNER, or anyone named in CODEOWNERS. Those are two sources answering different questions, since an outside collaborator can own a directory without being in the org.

Bots are refused whatever their association: a trigger a bot can fire is a loop, and this one spends money on every lap. COLLABORATOR is not enough on its own; a collaborator who should qualify is exactly the person CODEOWNERS names, and naming them is a decision somebody makes.

It is a Python function with tests rather than grep inside a YAML if:, because it is the whole authorization and YAML review is not a control.

The gate authorizes the asker, not the issue. Anyone can file an issue, and a member typing /implement on a drive-by hands that body to a model holding write tools. The ticket stays UNTRUSTED_EXTERNAL, writes are staged, ChangeGuard checks them twice, and the result arrives as a pull request a person reads. The gate bounds who can spend money; it does not bound what the text says.

For unattended runs, --approved-by "@login" replaces --approve and records who asked in the ledger. A grant nobody can be traced to is not much of a grant. Neither is an environment approval in the system of record; if you want one, the propose job declares environment: implement, and adding required reviewers to it in repository settings makes it real.

The three jobs and the two credentials are drawn as a sequence in chat-ops, and the workflow they drive in implement.

What bounds the spend is the actor gate, a per-issue concurrency group, --budget, and, where an organisation sets it, IN_LOCKSTEP_DAILY_LIMIT: a rolling 24-hour per-repository window summed from the ledger and refused before a run starts. It sums the ledger this clone can read, so a runner that never fetched lockstep-history (history --pull) sums less than the truth; row 3 of docs/controls-crosswalk.md records it as replaced and weaker. Without the variable, a member who wants to spend $2 forty times can.