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
36 changes: 21 additions & 15 deletions MANIFEST.sha256

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ namespace it opens. Written 2026-07-18 from the design session recorded in
- [specs/state-model.md](specs/state-model.md) — intent/state object-level
correspondence and the `effective_config` projection.
- [specs/canonical-json.md](specs/canonical-json.md) — `cic-canonical-json/1`: the exact JSON
value-equality and canonical form `plan` decides on (and a later plan digest will be
computed over).
value-equality and canonical form `plan` decides on and the plan digests are
computed over.
- [specs/plan-digest.md](specs/plan-digest.md) — `plan_digest`, `target_digest`,
`execution_digest` and the gate `execute` applies before it signs anything.

## Direction

Expand Down
13 changes: 9 additions & 4 deletions docs/design/manual-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,19 @@ running this against anything but an empty/disposable tenancy):

```bash
# ... same OCI_KEY_PATH/OCI_TENANCY_OCID/OCI_USER_OCID/OCI_FINGERPRINT/OCI_REGION/OCI_TEST_KIND as above, plus:
OCI_EXEC_OPERATION=CreateVcn OCI_EXEC_METHOD=POST OCI_EXEC_PATH=/vcns \
OCI_EXEC_OPERATION=CreateVcn \
OCI_EXEC_CONFIG_JSON='{"compartmentId":"...","displayName":"...","cidrBlock":"10.0.0.0/16"}' \
REAL_OCI_TEST=1 go test -tags manual_real_oci -count=1 -run TestManualRealOCIExecute -v ./module/
```

Omit `OCI_TEST_RESOURCE_ID` for `Create*` (the path has no `{param}` to fill);
set it for `Update*`/`Delete*` (`OCI_EXEC_PATH=/vcns/{vcnId}`, etc.). The
harness has no "list" capability of its own (the module ABI operates on one
Omit `OCI_TEST_RESOURCE_ID` for a create (the plan then has no resource to name);
set it to update an existing resource. Since the M4a plan binding (`docs/design/specs/plan-digest.md`)
the harness no longer takes `OCI_EXEC_METHOD`/`OCI_EXEC_PATH`: `Execute` only runs a plan it
can derive again from desired and observed state, so the harness observes the resource
(none for a create), asks `Plan` for the plan of `OCI_EXEC_CONFIG_JSON` on that target and
sends `Plan`'s answer to `Execute`. `OCI_EXEC_OPERATION` is an optional check that the plan
starts with that operation. A lone delete is `Destroy`'s job, not `Execute`'s. This rewritten
harness has not been run against real OCI (it compiles under its build tag). The harness has no "list" capability of its own (the module ABI operates on one
resource_id at a time) — after a `Create`, find the new resource's OCID with
the `oci` CLI (e.g. `oci network vcn list --query "...displayName=='...'"`)
to feed into the next `Observe`/`Update`/`Destroy` call.
Expand Down
8 changes: 4 additions & 4 deletions docs/design/specs/canonical-json.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# cic-canonical-json/1

The one definition this module uses for "these two JSON texts are the same value".
It decides whether a plan has any work to do (`plan`), and is the form a later digest
of a plan will be computed over. Go implements it (`module/canonical_json.go`); the
It decides whether a plan has any work to do (`plan`), and is the form the digests of a
plan are computed over. Go implements it (`module/canonical_json.go`); the
**normative expectations are the language-neutral vectors** in
`module/testdata/canonical_json_vectors.json`, so another implementation can be
checked against the same file.

Status: `plan` uses it today (**implemented**). Digests over it (`plan_digest`,
`target_digest`, `execution_digest`) are **concept** (not yet built).
Status: **implemented**. `plan` decides with it, and the plan, target and execution
digests are computed over it (`plan-digest.md`).

## Why not the standard library

Expand Down
116 changes: 116 additions & 0 deletions docs/design/specs/plan-digest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Plan binding: plan_digest, target_digest, execution_digest

M4a of the plan-binding work. `plan` returns three digests; `execute` derives the plan
again from the same inputs and refuses anything that does not match, before it signs
anything. Everything is computed over `cic-canonical-json/1` (`canonical-json.md`).
Go implements it (`module/plan_binding.go`); the **normative expectations are the
language-neutral vectors** in `module/testdata/plan_digest_vectors.json`, generated by
an independent implementation (no Go), so another implementation can be checked
against the same file.

Status: **implemented** in the module (derivation, the three digests, the gate in
`execute`). Approval, authority and single use of a plan are **concept** (M4b, the
Relay): nothing here provides them.

## What it proves, and what it does not

A request whose plan, desired state, observed state and target agree with each other,
and with the plan the provider derives from them again, is **internally consistent**.
That is all. It does not show that anyone approved the plan, that the caller may run
it, that the key belongs to the right tenant, or that it has not run before. A caller
who can change every input and every digest builds another consistent request. The
Relay must verify an approval that names `execution_digest` (M4b); until it does, a
consistent request runs (pinned by `TestM4aDoesNotClaimApproval` and
`TestM4aDoesNotPreventReplay`).

## The three digests

Each is `sha256:` and 64 hex digits over the canonical JSON text of an envelope. The
envelope is a JSON object, so every field is delimited by the JSON structure; nothing
is built by joining strings. Each has its own domain tag (`v`), so objects of
different roles never share a hash namespace.

- **`plan_digest`** (`v` = `cic-oracle-execution-plan/1`): everything the plan will
execute. Members: `contract`, `desired`, `kind`, `observed`, `operation`,
`provider_operations`, `v`.
- `desired` / `observed`: `data` (the canonical form of the payload's JSON),
`encoding`, `schema_hash`, `schema_id`, `schema_version`.
- `provider_operations`: the ordered list of `{method, operation, path}`.
- `contract`: the digest of the kind's registry contract (below).
- **`target_digest`** (`v` = `cic-oracle-execution-target/1`): where it runs. Members:
`base_path`, `host`, `key_id`, `kind`, `resource_id`, `revision`, `v`. `host` and
`key_id` are strings; `base_path`, `resource_id` and `revision` are `null` when
empty (an absent field and an empty one are the same value, by rule).
- **`execution_digest`** (`v` = `cic-oracle-execution/1`): `{plan_digest,
target_digest, v}`. It binds the two, so a plan cannot be paired with another,
equally valid, target. It is the value an approval will name.

Not in `plan_digest`: the `reason` of an operation and the plan's `notes`. They are
explanation; nothing executes or decides on them, and they can change without changing
the digest, on purpose. If any decision, policy or approval rule ever reads them they
must move into the envelope; an audit that must keep their exact text needs its own
evidence digest or signed record.

The `contract` digest covers what shapes a plan or an execution in the kind's
registry: its operations (name, method, path template, role) and fields (policy, JSON
type, action) and required fields. A module version that maps a change to other
operations has another contract digest, so an older plan no longer matches.

The name `plan_digest` is not the engine's `conformance_plan_digest`: that digests a
conformance plan and is a different thing. The domain tag keeps them apart.

## Plan: the lifecycle and the target

`plan` takes a `target` (`host`, `base_path`, `key_id`, `resource_id`, `revision`).
`host` and `key_id` are required and every field is bounded (1024 bytes);
`resource_id`, when present, must be a well-formed OCID of the kind. Whether a
resource id is required follows the lifecycle:

| observed | `target.resource_id` | Result |
|---|---|---|
| empty | empty | a **create** plan: `operation: create`, the kind's create operation |
| empty | present | refused (`observed.data`): a named resource has state |
| state | empty | refused (`target.resource_id`): an existing resource needs its id |
| state | present | the diff: `noop`, `update`, `action` or `replace` |

A create is planned from the desired config alone, so `plan` first requires that config
to be one the kind's contract accepts, and `execute` (which derives the plan again)
refuses it the same way: a missing required field (`validation`), a field no caller may
set or a gross type error (`schema`), each as `desired.data.<field>`; when there are
several problems the first by field path is named, so the answer does not depend on map
order. The check is the coarse one `validate` already does (required, known, JSON kind),
not a JSON Schema engine. An update is not held to the "required" rule: it is judged
by what changed.

The second row keeps the guard from cic-module-oracle-cloud#31: an empty observed
state can no longer be read as "every field changed" for a resource that was never
created.

## Execute: the gate

After the M1 id check and the M3 registry check, and before the first `sign`:

1. `plan.plan_id` must be absent (it is retired; `plan.plan_id` otherwise);
2. `plan.plan_digest`, `plan.target_digest`, `plan.execution_digest`, `desired` and
`observed` must be present;
3. the plan is **derived again** from `desired`, `observed` and the binding, and must
have the same `operation` and the same provider operations (name, method, path, in
the same order) as the plan received: swapping an update for an M3-valid delete is
caught here, not only by a digest;
4. the three digests must equal the ones computed from the request;
5. `config.data` must be the same JSON value as `desired.data` (canonical equality);
6. only then the first step is signed. The result echoes `execution_digest`.

Every refusal is `validation`, makes no sign and no actuate call, and names the
dimension in `field_path` (`plan.operation`, `plan.provider_operations[i]`,
`plan.plan_digest`, `plan.target_digest`, `plan.execution_digest`, `config.data`,
`desired.data`, `observed.data`, `binding.*`).

## Compatibility

`plan_id` is retired: `plan` no longer emits it and `execute` refuses a request that
carries one (even an empty or null one). No alias: an alias would be a way around the
digest check. A survey of the locally cloned CentralInfraCore repositories and the
cic-factory found no consumer of the module's `plan_id` (only a length check in this
module's own test); repositories not cloned here and other environments were not
checked.
21 changes: 17 additions & 4 deletions docs/design/specs/provider-abi.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ observation:
observed_at: 2026-07-18T...

execution-plan:
plan_id: sha256:... # the plan is hashable and signable
plan_digest: sha256:... # identifies what the plan executes (plan-digest.md)
target_digest: sha256:... # identifies where it runs
execution_digest: sha256:... # binds the two; the value an approval names
operation: create | update | replace | action | noop
provider_operations:
- { service: core, client: VirtualNetworkClient, operation: CreateVcn, request_model: CreateVcnDetails }
Expand Down Expand Up @@ -162,7 +164,7 @@ different values always do (two integers above 2^53 are never equal).

### What `execute` accepts before it sends anything

Two independent checks run over the **whole** plan before the first step is signed
Three independent checks run over the **whole** plan before the first step is signed
or sent, so a plan never half-executes. Each refusal is a `validation` error with
no sign and no actuate call.

Expand All @@ -182,9 +184,20 @@ no sign and no actuate call.
read is `observe`'s job and `plan` never emits one. `i` is the first
non-conforming step.

- **Plan binding (M4a).** `execute` takes the `desired` and `observed` state the plan was
made from, derives the plan again, and requires the same operation and the same
provider operations in the same order; the three digests (`plan_digest`,
`target_digest`, `execution_digest`) to equal the ones computed from the request;
`config.data` to be the same JSON value as `desired.data`; and no retired `plan_id`.
A create (no observed state, no resource id) additionally needs a desired config the
kind's contract accepts: required fields present, no field a caller cannot set, no
gross type error.
The details are in `plan-digest.md`. This shows the request is internally consistent,
not that it was approved or that it has not run before.

What these checks do **not** say: whether the id names the right resource, whether
the plan was approved, or whether the caller may run it. Those are the plan-binding
and Relay-authorization layers (status: concept).
the plan was approved, whether the caller may run it, or whether it ran already. Those
are the Relay-authorization layer (status: concept).

### What `poll` accepts

Expand Down
84 changes: 44 additions & 40 deletions module/binding_resource_id_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@ var bindingOps = []struct {
return Destroy(nil, marshalReq(t, destroyRequest{Kind: bindingKind, Binding: bindingWith(id)}))
}},
{"Execute (update step)", func(t *testing.T, id string) ([]byte, error) {
return Execute(nil, marshalReq(t, executeRequest{
Kind: bindingKind,
Plan: executionPlan{ProviderOperations: []providerOperation{updateVcnOp}},
Config: schemaPayload{Data: json.RawMessage(`{"displayName":"prod"}`)},
Binding: bindingWith(id),
}))
// A consistent M4a request for a well-formed id; for a bad one, the same
// request with the id replaced by hand, so the id check is what refuses it.
build := func(id string) executeRequest {
return m4aForOp(t, bindingKind, updateVcnOp, `{"displayName":"prod"}`, bindingWith(id))
}
req := build("ocid1.vcn.oc1..base")
if wellFormedOCID(bindingKind, id) {
req = build(id)
} else {
req.Binding.ResourceID = id
}
return Execute(nil, marshalReq(t, req))
}},
}

Expand Down Expand Up @@ -190,28 +196,45 @@ func TestAMixedPlanWithABadBindingIDRunsNoStep(t *testing.T) {
}
}

// A create has no id to splice (its path is /vcns), so a plan with only create steps
// must keep working whatever the binding carries in resource_id.
func TestACreateOnlyPlanIgnoresTheBindingResourceID(t *testing.T) {
for _, id := range []string{"", "not-an-ocid"} {
t.Run("binding id "+id, func(t *testing.T) {
// A create has no id to splice (its path is /vcns), so the M1 id check does not ask
// for one, and a create with no resource id runs. A create that names a resource id
// is not a create at all: the plan's lifecycle (M4a) refuses it, because a resource
// that does not exist yet has no id. M1 alone would ignore it; the refusal is M4a's.
func TestACreateRunsWithoutAResourceIDAndIsRefusedWithOne(t *testing.T) {
create := providerOperation{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}
t.Run("no resource id", func(t *testing.T) {
mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.vcn.oc1..created"}`, nil)}
restore := mock.wireHostSeam()
defer restore()
req := m4aForOp(t, bindingKind, create, `{"compartmentId":"ocid1.compartment..c"}`, bindingWith(""))
out, err := Execute(nil, marshalReq(t, req))
if err != nil {
t.Fatalf("Execute returned a Go error: %v", err)
}
if res := decodeResult(t, out); res.Status != "ok" {
t.Fatalf("status = %q, want ok (error: %+v)", res.Status, res.Error)
}
if mock.actuateCount() != 1 {
t.Errorf("%d provider requests, want exactly 1", mock.actuateCount())
}
})
for _, id := range []string{"not-an-ocid", "ocid1.vcn.oc1..existing"} {
t.Run("resource id "+id, func(t *testing.T) {
mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.vcn.oc1..created"}`, nil)}
restore := mock.wireHostSeam()
defer restore()
out, err := Execute(nil, marshalReq(t, executeRequest{
Kind: bindingKind,
Plan: executionPlan{ProviderOperations: []providerOperation{{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}}},
Config: schemaPayload{Data: json.RawMessage(`{"compartmentId":"ocid1.compartment..c"}`)},
Binding: bindingWith(id),
}))
req := m4aForOp(t, bindingKind, create, `{"compartmentId":"ocid1.compartment..c"}`, bindingWith(""))
req.Binding.ResourceID = id // changed by hand after the plan was built
out, err := Execute(nil, marshalReq(t, req))
if err != nil {
t.Fatalf("Execute returned a Go error: %v", err)
}
if res := decodeResult(t, out); res.Status != "ok" {
t.Fatalf("status = %q, want ok: a create must not depend on binding.resource_id (error: %+v)", res.Status, res.Error)
res := decodeResult(t, out)
if res.Status != "error" || res.Error == nil || res.Error.Class != classValidation {
t.Fatalf("status %q error %+v, want a validation error", res.Status, res.Error)
}
if mock.actuateCount() != 1 {
t.Errorf("%d provider requests, want exactly 1", mock.actuateCount())
if mock.signCount() != 0 || mock.actuateCount() != 0 {
t.Errorf("%d sign / %d actuate calls, want none", mock.signCount(), mock.actuateCount())
}
})
}
Expand Down Expand Up @@ -264,22 +287,3 @@ func TestACreateNamedStepWhosePathSplicesTheIDIsRefusedOnABadID(t *testing.T) {
})
}
}

// A create-named step whose path has no placeholder still does not need the id.
func TestACreateNamedStepWithAPlainPathStillIgnoresTheID(t *testing.T) {
mock := &cicFlowMock{Reply: replyWith(200, `{"id":"ocid1.vcn.oc1..created"}`, nil)}
restore := mock.wireHostSeam()
defer restore()
out, err := Execute(nil, marshalReq(t, executeRequest{
Kind: bindingKind,
Plan: executionPlan{ProviderOperations: []providerOperation{{Operation: "CreateVcn", Method: "POST", Path: "/vcns"}}},
Config: schemaPayload{Data: json.RawMessage(`{"compartmentId":"ocid1.compartment..c"}`)},
Binding: bindingWith("not-an-ocid"),
}))
if err != nil {
t.Fatal(err)
}
if res := decodeResult(t, out); res.Status != "ok" {
t.Fatalf("status %q, want ok (error: %+v)", res.Status, res.Error)
}
}
19 changes: 11 additions & 8 deletions module/execute_bytes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,18 @@ func TestExecuteNonCreateOutputBytesUnchanged(t *testing.T) {
restore := mock.wireHostSeam()
defer restore()

req, _ := json.Marshal(executeRequest{
Kind: "cic:network:vcn",
Plan: executionPlan{ProviderOperations: []providerOperation{tc.po}},
Config: schemaPayload{Data: json.RawMessage(tc.config)},
Binding: execBinding{Host: "h", BasePath: "/20160918", KeyID: "k", ResourceID: "ocid1.vcn..z", Revision: "etag-42"},
})
out, err := Execute(nil, req)
// The step mapping is checked on the steps themselves (executeSteps), so a
// single DeleteVcn or ChangeVcnCompartment step can be exercised on its own;
// whether a plan may run at all is the gate's business (m4a_gate_test.go),
// and TestExecuteOutputOnlyAddsTheExecutionDigest ties the two together.
config, err := decodeObject(json.RawMessage(tc.config))
if err != nil {
t.Fatalf("Execute returned a Go error: %v", err)
t.Fatalf("config: %v", err)
}
out, err := okResult(executeSteps(resourceContracts()["cic:network:vcn"], []providerOperation{tc.po}, config,
execBinding{Host: "h", BasePath: "/20160918", KeyID: "k", ResourceID: "ocid1.vcn..z", Revision: "etag-42"}))
if err != nil {
t.Fatalf("okResult returned a Go error: %v", err)
}
if string(out) != tc.want {
t.Errorf("Execute output changed for a non-create operation.\n got: %s\nwant: %s", out, tc.want)
Expand Down
7 changes: 1 addition & 6 deletions module/execute_create_id_guest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,7 @@ func execStepThroughWasm(t *testing.T, mock *cicFlowMock, po providerOperation,
t.Helper()
ctx, instance, callFn, allocateFn, deallocateFn := loadGuestWithCicFlow(t, mock)

reqJSON, _ := json.Marshal(executeRequest{
Kind: "cic:network:vcn",
Plan: executionPlan{Operation: "create", ProviderOperations: []providerOperation{po}},
Config: schemaPayload{SchemaID: "cic:network:vcn-config", Encoding: encCanonicalJSON, Data: json.RawMessage(configJSON)},
Binding: binding,
})
reqJSON, _ := json.Marshal(m4aForOp(t, "cic:network:vcn", po, configJSON, binding))
env := callOp(t, ctx, instance, callFn, allocateFn, deallocateFn, "execute", "{}", string(reqJSON))
if string(env.Error) != "null" {
t.Fatalf("execute transport error = %s, want null", env.Error)
Expand Down
Loading
Loading