Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions labs/21-interlock/integrations/pitot/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<correlation>","data":{ /* the EffectRequest */ }}
{"pitot_version":"1","type":"control.requested","kind":"interlock.effect","action_id":"<correlation>","data":{ /* the EffectRequest */ }}
```

`control.response` (controller → host):
Expand All @@ -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":"<same correlation>","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
Expand All @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions labs/21-interlock/integrations/pitot/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
47 changes: 47 additions & 0 deletions labs/21-interlock/integrations/pitot/adapter/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions labs/21-interlock/integrations/pitot/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <kind>`). 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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions labs/21-interlock/public-readme-preview/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions labs/21-interlock/public-readme-preview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading