diff --git a/labs/21-interlock/integrations/pitot/SPEC.md b/labs/21-interlock/integrations/pitot/SPEC.md index 64ae20efd..60a0295b8 100644 --- a/labs/21-interlock/integrations/pitot/SPEC.md +++ b/labs/21-interlock/integrations/pitot/SPEC.md @@ -221,7 +221,7 @@ JSONL over stdio (one JSON object per line). Every message carries `control.requested` (host → controller): ```json -{"pitot_version":"1","type":"control.requested","kind":"interlock.decide","action_id":"","data":{ /* the EffectRequest */ }} +{"pitot_version":"1","type":"control.requested","kind":"interlock.effect","action_id":"","data":{ /* the EffectRequest */ }} ``` `control.response` (controller → host): @@ -230,7 +230,9 @@ JSONL over stdio (one JSON object per line). Every message carries {"pitot_version":"1","type":"control.response","controller_id":"…","action_id":"","outcome":"allow","message":"…"} ``` -- `kind` selects which controller handles the request (e.g. `interlock.decide`). +- `kind` selects which controller handles the request — the canonical value is + `interlock.effect` (`client.DefaultRequestKind`), and the config `kind:` map key + must match it or Pitot finds no controller and fails closed on every request. - `data` is the opaque payload — for this path, the Interlock `EffectRequest`. - `action_id` correlates request↔response; the runtime mints an unpredictable one. - `outcome` on the wire is only **`allow` | `deny`** — the controller collapses the @@ -254,7 +256,8 @@ dependency-free. It has three parts: takes the request as raw bytes, runs `engine.Decide`, and returns allow only on `OutcomeAllow` (everything else fails closed). Unit-testable with no runtime. - **`cmd/interlock-pitot-controller`** — owns the Pitot wire framing (via `sdk`) - and calls the adapter. This is the controller `kind: interlock.decide` points at. + and calls the adapter. This is the controller the config `kind: interlock.effect` + points at. - **`client`** — the host-side typed decision client. Names the resource by id, builds the typed `EffectRequest`, sends it via Pitot, returns a typed result, and fails closed on build/transport/non-allow. diff --git a/labs/21-interlock/integrations/pitot/adapter/adapter.go b/labs/21-interlock/integrations/pitot/adapter/adapter.go index 7c593f103..1857ae312 100644 --- a/labs/21-interlock/integrations/pitot/adapter/adapter.go +++ b/labs/21-interlock/integrations/pitot/adapter/adapter.go @@ -38,6 +38,19 @@ func Evaluate(policy ir.Policy, data []byte) (allow bool, message string) { dec := json.NewDecoder(strings.NewReader(string(data))) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { + // A decode failure is fail-closed either way, but the caller needs to tell + // two very different causes apart. An unknown-field error is schema skew — + // the caller is almost always a newer client sending a field this + // controller's protocol version does not know — not a policy denial or + // corrupt bytes. Naming it as such points the operator at a version + // mismatch (upgrade the controller / align the client) instead of hunting a + // phantom policy rule. Any other decode error is a genuinely malformed + // payload. Neither is ever confusable with an engine deny below. + if strings.Contains(err.Error(), "unknown field") { + return false, fmt.Sprintf("interlock: schema skew — effect request has a field this controller does not recognize; "+ + "the caller is likely a newer client than this controller's protocol %s (fail closed): %v", + protocol.EffectRequestProtocol, err) + } return false, fmt.Sprintf("interlock: undecodable effect request (fail closed): %v", err) } diff --git a/labs/21-interlock/integrations/pitot/adapter/adapter_test.go b/labs/21-interlock/integrations/pitot/adapter/adapter_test.go index e12b76314..f2df074c8 100644 --- a/labs/21-interlock/integrations/pitot/adapter/adapter_test.go +++ b/labs/21-interlock/integrations/pitot/adapter/adapter_test.go @@ -105,6 +105,53 @@ func TestEvaluateRequireBecomesDenyWithMissingEvidence(t *testing.T) { } } +func TestEvaluateSchemaSkewIsDiagnosableAndDistinct(t *testing.T) { + pol, hash := testPolicy(t) + // An otherwise well-formed request carrying one field this controller does not + // know — the shape a newer client sends against an older controller. It must + // fail closed (like every decode error), but its message must point at a + // version skew, name the controller's protocol, and NOT read as a policy deny. + data := []byte(`{ + "protocol":"interlock.effect.v1","request_id":"r4","run_id":"run1", + "actor":"publisher","operation":"artifact.publish", + "resource":{"kind":"file","uri":"repo://out/result.json"}, + "claimed_policy_hash":"` + hash + `", + "evidence":[{"kind":"staged_hash_match"}], + "future_field":"from a newer client" + }`) + allow, msg := Evaluate(pol, data) + if allow { + t.Fatalf("schema skew must fail closed, got allow: %s", msg) + } + if !strings.Contains(msg, "schema skew") { + t.Errorf("skew message should name the schema skew explicitly: %q", msg) + } + if !strings.Contains(msg, "interlock.effect.v1") { + t.Errorf("skew message should name the controller protocol version: %q", msg) + } + // It must be distinguishable from a policy denial: an operator reading it must + // not go hunting for a phantom rule. + if strings.Contains(msg, "rule") { + t.Errorf("skew message must not read as a policy deny (no rule mention): %q", msg) + } + // And distinct from a generic malformed-payload message. + if strings.Contains(msg, "undecodable") { + t.Errorf("skew message should be labeled schema skew, not generic undecodable: %q", msg) + } + + // A genuinely malformed payload keeps the generic diagnostic — not the skew one. + allow, msg = Evaluate(pol, []byte("{not json")) + if allow { + t.Fatalf("malformed data must fail closed") + } + if strings.Contains(msg, "schema skew") { + t.Errorf("corrupt bytes should not be reported as schema skew: %q", msg) + } + if !strings.Contains(msg, "undecodable") { + t.Errorf("corrupt bytes should keep the generic undecodable diagnostic: %q", msg) + } +} + func TestEvaluateFailsClosedOnMalformedData(t *testing.T) { pol, _ := testPolicy(t) for _, tc := range []struct { diff --git a/labs/21-interlock/integrations/pitot/client/client.go b/labs/21-interlock/integrations/pitot/client/client.go index bde53dda7..87a5fd9de 100644 --- a/labs/21-interlock/integrations/pitot/client/client.go +++ b/labs/21-interlock/integrations/pitot/client/client.go @@ -23,9 +23,19 @@ import ( "github.com/operatorstack/pitot/schema" ) -// DefaultRequestKind is the conventional Pitot request kind an Interlock decision -// controller registers under. A deployment may choose another; pass it to New. -const DefaultRequestKind = "interlock.decide" +// DefaultRequestKind is the canonical Pitot request kind an Interlock decision +// controller is registered under. It is the single source of truth for that +// string: the Pitot config's controller map key (`kind:`), the documentation, and +// scripts/validate.sh all use this same value, and kind_consistency_test.go fails +// the build if any of them drift. +// +// Why this matters: Pitot routes a request to a controller by matching the +// request kind against the config `kind:`. If the two disagree, Pitot finds no +// controller and fails closed — every decision is denied, silently, with no +// engine ever consulted. Keeping one canonical value avoids that footgun; a +// deployment that deliberately chooses another kind must pass the same string to +// both New and the controller's config `kind:`. +const DefaultRequestKind = "interlock.effect" // Client issues Interlock decision requests over a Pitot runtime. type Client struct { diff --git a/labs/21-interlock/integrations/pitot/client/kind_consistency_test.go b/labs/21-interlock/integrations/pitot/client/kind_consistency_test.go new file mode 100644 index 000000000..a12af26f2 --- /dev/null +++ b/labs/21-interlock/integrations/pitot/client/kind_consistency_test.go @@ -0,0 +1,52 @@ +package client + +import ( + "os" + "strings" + "testing" +) + +// The Pitot request kind lives in three places that must never disagree: the +// DefaultRequestKind constant (what the client sends), the config fragment in the +// integration README (the controller map key `kind:`), and scripts/validate.sh +// (which registers the controller and drives `pitot request `). Pitot routes +// by matching the request kind against the config `kind:`; if they drift, Pitot +// finds no controller and fails closed on every request — a silent deny-all. These +// tests fail the build the moment any of the three moves without the others. + +// canonicalKind is the value the whole integration is expected to converge on. +// It is stated here independently of DefaultRequestKind so a change to the +// constant is a deliberate, reviewed edit to this test too, not a silent rename. +const canonicalKind = "interlock.effect" + +// staleKind is the previous value; no shipped surface may reference it as a kind. +const staleKind = "interlock.decide" + +func TestDefaultRequestKindIsCanonical(t *testing.T) { + if DefaultRequestKind != canonicalKind { + t.Fatalf("DefaultRequestKind = %q, want %q", DefaultRequestKind, canonicalKind) + } +} + +func TestDocsAndScriptAgreeOnKind(t *testing.T) { + // Paths are relative to this package dir (integrations/pitot/client). + surfaces := map[string]string{ + "integration README": "../README.md", + "validate.sh": "../scripts/validate.sh", + } + for name, path := range surfaces { + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + text := string(b) + if !strings.Contains(text, DefaultRequestKind) { + t.Errorf("%s (%s) does not mention the canonical request kind %q; it must register the controller under this kind or Pitot fails closed on every request", + name, path, DefaultRequestKind) + } + if strings.Contains(text, staleKind) { + t.Errorf("%s (%s) still references the stale request kind %q; a config/client kind mismatch silently denies every request", + name, path, staleKind) + } + } +} diff --git a/labs/21-interlock/interlock-distribution/release-notes/2026-07-31-interface-and-client-readiness.md b/labs/21-interlock/interlock-distribution/release-notes/2026-07-31-interface-and-client-readiness.md new file mode 100644 index 000000000..c87f13821 --- /dev/null +++ b/labs/21-interlock/interlock-distribution/release-notes/2026-07-31-interface-and-client-readiness.md @@ -0,0 +1,42 @@ +### Start building over Pitot without the silent deny-all footgun + +This release hardens the Interlock↔Pitot decision-transport interface and the +language clients so external builders can start testing against them — with no +change to the enforcement guarantee. The engine still decides, the broker still +performs, and every path still fails closed. What changed is that the two ways a +newcomer gets *silently* stuck are now *loudly* diagnosed. + +**One canonical request kind, guarded against drift.** Pitot routes a request to a +controller by matching the request kind against the config key; if the client's +kind and the config key disagree, Pitot finds no controller and denies **every** +request silently, with no engine ever consulted. The client, the integration +README, and `scripts/validate.sh` now all converge on one canonical value +(`client.DefaultRequestKind = "interlock.effect"`), and a build-time drift guard +fails CI the moment any of them moves apart. A mismatch is now a failed build, not +a mysterious deny-all in production. + +**Schema skew reads as schema skew.** When a newer client sends a field an older +controller does not recognize, the controller still fails closed — but the message +now names it as a version skew against the controller's protocol +(`interlock.effect.v1`), distinct from both a corrupt payload and a policy denial. +An operator reading the response is pointed at "upgrade the controller / align the +client," not sent hunting for a phantom policy rule. + +**The language clients ship as one version-locked release.** A static CI check +(no toolchain required) fails the build if the TypeScript and Python package +versions drift, so an external builder can never pin mismatched clients against one +controller and silently reason about two different wire contracts. This rides the +existing client bar: generated-from-Go types (diff-gated), frozen-corpus canonical +parity, and the no-foreign-enforcement guardrail. + +**A clearer onramp for building on Interlock.** The public README gains a *Build on +Interlock* section — installing and running the TypeScript/Python clients, and +running a decision over Pitot with the canonical request kind — plus a +`CONTRIBUTING.md` covering the six-point client support bar and the one guardrail +that cannot move: enforcement stays the trusted Go executable. Clients transport, +shape, hash, and observe decisions; they never decide or enforce. + +None of this touches the enforcement boundary. The Pitot leg remains +decision-transport, not the guarantee — the broker is still the sole authority that +lands the protected artifact, only on a policy-allowed request with truthful, +hash-bound evidence. diff --git a/labs/21-interlock/public-readme-preview/CONTRIBUTING.md b/labs/21-interlock/public-readme-preview/CONTRIBUTING.md new file mode 100644 index 000000000..e1ed14f3f --- /dev/null +++ b/labs/21-interlock/public-readme-preview/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Interlock + +Interlock's core guarantee is **authority, not interception**: one protected effect +is landed only by the broker, only on a policy-allowed request with truthful +evidence. Contributions must preserve that separation — and the honesty about what +is and is not guaranteed. Read [The enforcement boundary](README.md#the-enforcement-boundary-what-is-and-isnt-guaranteed) +before proposing a change. + +## The one guardrail that cannot move + +**Enforcement stays the trusted Go executable.** The engine decides and the broker +performs; nothing else does. A language client, an integration, or an example may +**transport, shape, hash, or observe** a decision — it may never **decide** or +**enforce** one. + +Concretely, forbidden anywhere outside the Go engine/broker: + +- a `decide` / `publish` / `broker` callable, or a decider/publisher/broker class; +- inferring a file effect from a command string (Interlock rejects this dishonesty — + a command string does not describe an opaque process's writes); +- an arbitrary decision-time callback (it would reintroduce hidden I/O, + nondeterminism, and replay failure into the one component that must stay pure). + +[`scripts/check-clients.sh`](https://github.com/operatorstack/intelligence-flow/blob/main/labs/21-interlock/scripts/check-clients.sh) +fails the build if any client grows an executable enforcement surface. + +## Adding or changing a language client + +The clients carry protocol **data types** and a **canonical encoder** only. Every +shipped language must clear the same six-point support bar (all CI-enforced): + +1. the generator is not stale — types are generated from the Go wire structs + (`go run ./clients/gen/main.go`) and diff-gated, so a DTO can never drift from + the protocol it mirrors; +2. the types compile / import; +3. the package installs; +4. the canonical hash matches the **frozen Go corpus** (`conformance/compat/v0.1.0/`) + — this is the real bar, not "the structs compile"; +5. the decision fixtures round-trip through the DTOs within the closed vocabulary; +6. the example runs and exits 0. + +Two more standing rules for clients: + +- **Version lockstep.** All language clients ship as one release; bump every + package to the same version together. A static CI check fails the build on drift, + even where a toolchain is absent. +- **No foreign enforcement.** See the guardrail above — a client that grows a + `decide`/`publish`/`broker` callable is rejected, type declarations aside. + +Do not hand-edit a generated file; change the Go source struct and regenerate. + +## Changing the protocol or the Pitot transport + +- Any change to the wire types is a change to the **single source of truth** (the Go + structs). Regenerate the clients and the JSON schema, and update the frozen corpus + deliberately — a changed *old* hash or *old* decision is a **breaking change** and + is meant to fail CI. +- The Pitot integration ([`integrations/pitot`](integrations/pitot)) lives in its + own module; the interlock core never imports Pitot. Keep the request kind + canonical (`client.DefaultRequestKind`) — the drift guard exists because a + client/config kind mismatch silently denies every request. + +## Development + +The reference implementation is a Go module. From the lab directory: + +```bash +bash scripts/validate.sh # gofmt, vet, tests (+ -race), purity boundary, + # IR determinism, CGO_ENABLED=0 build, CLI lifecycle, + # and check-clients.sh +bash scripts/check-determinism.sh # equivalent Go source → identical IR + hash +``` + +Fixtures live under `conformance/` as embedded positive/negative vectors: add a +positive vector for every new behavior and a negative control for every boundary +fault. The engine stays pure — no I/O, deterministic — so it can be replayed; real +hashing and receipt correlation live in the broker. diff --git a/labs/21-interlock/public-readme-preview/README.md b/labs/21-interlock/public-readme-preview/README.md index 571ca7eb4..3a0dc1edb 100644 --- a/labs/21-interlock/public-readme-preview/README.md +++ b/labs/21-interlock/public-readme-preview/README.md @@ -277,6 +277,91 @@ by the broker tests, not just asserted here. See [Enforcement model — transport is not authority](docs/concepts/enforcement-model.md) for how this guarantee holds across local, agent, and cloud-sandbox environments. +## Build on Interlock + +Two supported ways to build against Interlock, both honest about the boundary +above: they **transport and shape** decisions; they never decide or enforce. + +### Language clients (TypeScript, Python) + +The `clients/` directory ships generated protocol types plus a hand-written, +parity-gated canonical encoder for **TypeScript** and **Python**. They carry +exactly three things — the wire **data types**, the **canonical encoder** that +produces the same bytes (and therefore the same SHA-256) as the Go source, and the +ergonomics to **shape** a request. They carry **no `decide`, no `publish`, no +broker**: enforcement stays the trusted Go executable, and a build-time guardrail +([`scripts/check-clients.sh`](https://github.com/operatorstack/intelligence-flow/blob/main/labs/21-interlock/scripts/check-clients.sh)) +fails the build if a client ever grows an executable decision surface. + +The two packages ship as **one version-locked release** — the same CI gate fails +the build if the TypeScript and Python package versions drift, so an external +builder can never pin mismatched clients against one controller. + +```bash +# TypeScript (Node >= 22 runs .ts natively; zero runtime deps) +cd clients/typescript && npm install +node examples/decision-request.ts # resolve a resource by id → typed, canonically-encoded EffectRequest + +# Python (>= 3.11; zero runtime deps — stdlib hashlib/json only) +cd clients/python && pip install -e . +python examples/decision-request.py +``` + +The types are **generated from the Go wire structs** (`go run ./clients/gen/main.go`) +and diff-gated in CI, so a DTO can never silently drift from the protocol. Each +client's canonical encoder is proven byte-for-byte against a **frozen Go corpus** +(`conformance/compat/v0.1.0/`): the parity example re-canonicalizes every frozen +policy and asserts the hash matches. That frozen-corpus parity is the real bar — +"a client compiles" is not enough to ship one. + +What a client is *for*: hashing a policy identically across languages, and shaping +an `EffectRequest` to send to a decision controller. What it is **not** for: +deciding the request (that is the Go engine) or performing the effect (that is the +Go broker). Porting either into another language is an explicit non-goal. + +### Run a decision over Pitot + +To route a decision through a running host, Interlock ships an ordinary +[Pitot](https://github.com/operatorstack/pitot) subprocess Controller in a separate +module ([`integrations/pitot`](integrations/pitot)) — **no Pitot source change**, +and the interlock core never imports Pitot. Pitot launches the controller and +streams `control.requested` events; the controller runs the pure `engine.Decide` +and answers allow/deny, with Pitot's deadline and fail-closed +`on_timeout`/`on_unavailable` defaults layered on top. + +```bash +# the integration is its own Go module (keeps the interlock core Pitot-free) +cd integrations/pitot && go build -o interlock-pitot-controller ./cmd/interlock-pitot-controller +interlock compile ./examples/exclusive-publish -o policy.json +``` + +Register the controller under the canonical request kind `interlock.effect`, then +issue a normal Pitot request whose `data` is an Interlock `EffectRequest`: + +```yaml +controllers: + interlock.effect: # the request kind Pitot routes on + id: interlock-effect + command: [./interlock-pitot-controller, --id, interlock-effect, --policy, policy.json] + deadline_ms: 5000 + on_timeout: deny + on_unavailable: deny +``` + +```bash +pitot run --config config.yaml --runtime rt.json & +pitot request interlock.effect --runtime rt.json --data '{"protocol":"interlock.effect.v1", ...}' +``` + +**One request kind, or fail closed.** Pitot routes a request to a controller by +matching the request kind against the config key. If the client's kind and the +config key disagree, Pitot finds no controller and denies **every** request +silently — an engine is never consulted. Interlock keeps a single canonical value +(`client.DefaultRequestKind = "interlock.effect"`) and a build-time drift guard +that fails CI the moment the constant, the README, or `scripts/validate.sh` move +apart, so a mismatch is diagnosed, never silent. This is transport plumbing, not +the enforcement guarantee — the broker remains that. + ## Install **Prebuilt binary (no Go toolchain).** The installer detects your platform, diff --git a/labs/21-interlock/scripts/check-clients.sh b/labs/21-interlock/scripts/check-clients.sh index 871ed6dd2..ce04d67ab 100755 --- a/labs/21-interlock/scripts/check-clients.sh +++ b/labs/21-interlock/scripts/check-clients.sh @@ -79,6 +79,31 @@ if ! git diff --exit-code -- \ fi echo "ok (generated types match the Go source structs)" +# --------------------------------------------------------------------------- +# 2a'. Version lockstep — the language clients ship as ONE release. An external +# builder installs the TypeScript and Python clients expecting the same +# protocol surface; if the two package versions drift, a consumer can pin +# mismatched clients against one controller and silently reason about two +# different wire contracts. This is a static file check (no toolchain), so it +# runs even where node/python are absent — a skipped bar must never let a +# version skew through. Bump both packages together or this fails the build. +# --------------------------------------------------------------------------- +step "clients are version-locked (TypeScript and Python share one release version)" +ts_pkg="clients/typescript/package.json" +py_pkg="clients/python/pyproject.toml" +ts_ver="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ts_pkg" | head -1)" +py_ver="$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$py_pkg" | head -1)" +if [ -z "$ts_ver" ] || [ -z "$py_ver" ]; then + echo "could not read a client version (typescript='$ts_ver' python='$py_ver')" + exit 1 +fi +if [ "$ts_ver" != "$py_ver" ]; then + echo "client version drift: typescript=$ts_ver python=$py_ver" + echo "the language clients ship as one release; bump both packages to the same version" + exit 1 +fi +echo "ok (both clients at v$ts_ver)" + # --------------------------------------------------------------------------- # 2b. TypeScript support bar (gated on Node >= 22, which runs .ts natively). # ---------------------------------------------------------------------------