Skip to content

module: bind execute to its plan (M4a): plan, target and execution digests, and a derive-again gate - #43

Merged
sinkog merged 2 commits into
develfrom
feature/m4a-plan-binding
Nov 8, 2026
Merged

sinkog merged 2 commits into
develfrom
feature/m4a-plan-binding

Conversation

@sinkog

@sinkog sinkog commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

What

M4a: execute now runs only a plan it can derive again from the desired and observed state it was made from, on the target it was made for. Before this, execute ran whatever plan_id, config, binding and operations the request carried: measured on devel with the same plan_id, a changed config, resource_id, host, base_path, key_id or revision, a swapped M3-valid DeleteVcn, or a reversed step order all executed, and one approved request ran any number of times. The old planHash also left out schema_version, encoding, the provider operations and the target, hashed raw JSON bytes, and joined its fields with no separator.

  • Three digests (plan_digest, target_digest, execution_digest): sha256 over canonical JSON (cic-canonical-json/1) envelopes, each with its own domain tag, no string concatenation, an absent field and an empty one both null.
    • plan_digest: kind, the kind's contract digest, full desired and observed (canonical data plus every schema field), operation, ordered provider_operations. reason and notes are not in it (explanation only; a documented, deliberate property).
    • target_digest: host, base_path, key_id, resource_id, revision.
    • execution_digest: binds the two; it is the value an approval will name (M4b).
  • plan_id is retired, no alias: plan stops emitting it and execute refuses a request that carries one (even empty or null). A survey of the locally cloned CentralInfraCore repositories and cic-factory found no consumer of the module's plan_id (only a length check in this module's own test); repos not cloned here and other environments were not checked.
  • plan takes a target and is lifecycle-aware: no observed state and no resource id is a create plan; observed state needs a well-formed resource id; the other two combinations are refused (the Plan() silently mis-plans a DeleteVcn against a nonexistent resource when observed is empty #31 guard stays). host and key_id are required; every field is bounded.
  • execute gate, before the first sign: after M1 and M3: no plan_id; the three digests, desired and observed present; the plan derived again must have the same operation and the same provider operations in the same order; the three digests must match; config.data must be the same JSON value as desired.data. Every refusal is validation with the dimension in field_path and zero sign / zero actuate; the result echoes execution_digest.
  • Language-neutral vectors (module/testdata/plan_digest_vectors.json, 16 plan / 11 target / 4 execution) generated by an independent implementation (no Go); spec in docs/design/specs/plan-digest.md.

What this does NOT show

A consistent request is internally consistent, nothing more: not approved, not authorised, not single-use. A caller who can change every input and every digest builds another consistent request. Approval, authority, the key's tenant, replay and concurrency are M4b (the Relay). Two tests pin this so nobody reads more into M4a: TestM4aDoesNotClaimApproval (a request the caller invented, unapproved by anyone, runs) and TestM4aDoesNotPreventReplay (the same request runs three times). Both run on the mocked host seam only; M4b turns them around.

Evidence

  • Negative tests change ONE thing in a request that the real Plan produced (a field, or the request text), not the helper that builds good ones: ~30 mismatches (config, desired, observed, schema_version/encoding, operation, ops appended/removed/swapped/replaced incl. the M3-valid delete, resource_id, host, base_path, key_id, revision, missing/garbage/swapped digests, another plan's digests, a valid plan paired with another valid target) plus four hand-edited request texts; a bad step in a two-step plan runs no step. Each: validation, expected field_path, 0 sign / 0 actuate.
  • Positive controls: the same state spelled differently (key order, whitespace, escapes, 1 / 1.0 / 10e-1) gives one plan_digest, and a request that spells it differently from the plan still runs.
  • GREEN: full suite; gofmt, vet, staticcheck clean (also go vet -tags manual_real_oci); the four real-WASM tests PASS against a fresh guest.
  • Mutants (18, all compile, all fail a test): gate off entirely, operation / count / per-step compare off, each of the three digest compares off, config equality off, plan_id ban off, contract digest constant, revision / encoding dropped from the envelopes, raw data instead of canonical, operations sorted before digesting, "derive" from the plan's own operations, resource-id check off, lifecycle branch off, execution tag = target tag.
  • RED is by mutation, not on unfixed code: the pre-change behaviour was measured on devel before this work (all the dimension changes above executed, replay 2x/5x), and the gate-off mutant fails the new gate tests; the new tests reference new symbols, so they cannot be compiled against the old tree.

Review F1 (a create must come from a config the contract accepts) - decided

Plan(create) takes on intent conformance. Before it plans a create (no observed state, no resource id) it applies the contract check validate already does: required fields present, no field a caller cannot set, no gross JSON-kind error. execute, which derives the plan again, refuses the same configs. A refusal names desired.data.<field> (validation for a missing required field, schema for an unknown field or wrong type) with zero sign / zero actuate; with 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 does, not a JSON Schema engine. An update is not held to the "required" rule. Tests: plan_create_conformance_test.go (8 Plan cases, 3 hand-edited requests plus an edit of the request text, controls, determinism); RED on the previous provider.go: all 12 negative cases fail; 5 compiling mutants all fail a test.

Behaviour changes to review

  1. plan now plans a create (no observed state and no resource id). It was needed because execute only runs a derivable plan and creation used to go through a hand-built execute.
  2. A single step that plan cannot produce (a lone DeleteVcn, AddVcnCidr) is no longer runnable through execute. A delete is destroy's job.
  3. The execute output gains execution_digest; the step results are unchanged (tested: the output minus the digest equals the steps run directly).
  4. Existing tests: Execute call sites now build requests through the real Plan (m4a_helpers_test.go); the step-output bytes test runs executeSteps directly; the "every registry operation through Execute" test is now a registry-check unit test plus a derivable-plan integration test; one module: Execute runs only registry operations with the registry's method and path (M3) #40 F1 test (create-named step, plain path, junk id) was removed because the M4a target check now refuses that request before M1 alone could be observed.
  5. The manual_real_oci_test.go Execute harness was rewritten (Observe -> Plan -> Execute; OCI_EXEC_METHOD/OCI_EXEC_PATH gone). It compiles under its tag; it was not run against real OCI.

Limits / not in this PR

  • No approval, authority or single use (M4b, Relay). The Relay is not changed.
  • module.wasm is not rebuilt or re-signed; the committed artifact predates this source, as before. No production GO.
  • No merge without explicit owner GO.

🤖 Generated with Claude Code

Gábor Z Sinkó added 2 commits November 8, 2026 13:28
…gests, and a derive-again gate

Execute ran whatever plan_id, config, binding and operations the request carried:
with the same plan_id a changed config, resource_id, host, base_path, key_id,
revision, or a swapped (M3-valid) delete all executed, and the same request ran
any number of times. The old plan_hash also left out schema_version, encoding,
the provider operations and the target, hashed raw JSON bytes, and joined its
fields with no separator.

plan now takes a target and returns plan_digest, target_digest and
execution_digest: sha256 over canonical JSON (cic-canonical-json/1) envelopes with
their own domain tags, no string concatenation, an absent field and an empty one
the same null. execution_digest binds plan and target and is the value an approval
will name. plan_id is retired, with no alias.

Plan's lifecycle decides what the target must carry: no observed state and no
resource id is a create; observed state needs a well-formed resource id; the
other combinations are refused (the cic-module-oracle-cloud#31 guard stays).

Execute derives the plan again from desired, observed and the binding and requires
the same operation and provider operations in the same order, the three digests,
and config equal to desired as a JSON value, all before the first sign. The result
echoes execution_digest. The expectations for the digests are language-neutral
vectors made by an independent implementation.

This shows a request is internally consistent. It does not show approval, authority
or single use (M4b, the Relay): two tests pin that a self-consistent request is not
approved and that the same one can run again. Existing Execute tests now build their
requests through the real Plan; the step-output bytes test runs the steps directly.

---
[signing-metadata]
key = cic-my-sign-key
signature = vault:v1:MEQCIBdcnJ/2KyY5S7fLMOOG9p5Ce5hLaCI7TdW5Z2DylRU3AiAv/aYJVHMA/ks3sAeyB7rvoYAqzY6IeEqjtrd0C22PxA==
hash-algorithm = sha256
digest = aFAtqS2oAdhBiPEVcrY0QiRVQxpPnwRcO3WT8Zauq5A=

[certificate]
-----BEGIN CERTIFICATE-----
MIICBjCCAaygAwIBAgIUSnRMR6RPnEbg296XWPOqq/u5PCwwCgYIKoZIzj0EAwIw
QzELMAkGA1UEBhMCSFUxGTAXBgNVBAoMEENlbnRyYWxJbmZyYUNvcmUxGTAXBgNV
BAMMEENJQyBEZXZlbG9wZXIgQ0EwHhcNMjYwMzIwMTMyMjU5WhcNMjYxMjMxMTMy
MjU5WjBFMQswCQYDVQQGEwJIVTEZMBcGA1UECgwQQ2VudHJhbEluZnJhQ29yZTEb
MBkGA1UEAwwSR2Fib3IgWm9sdGFuIFNpbmtvMFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEIG2CVmTfmLB9pLLclj7YmP2eedAjklpy4LGrU2ijoiy6Xqpuybv7OgJe
i+ez31s65NEV8+X/ByeX1cstR988z6N8MHowCQYDVR0TBAIwADAdBgNVHQ4EFgQU
yZN6AIX/TNnIJ9GwAa/NRN3ujHAwHwYDVR0jBBgwFoAUXn6CHYzPUqU4JVP8g+OS
WeDYjhcwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEF
BQcDBDAKBggqhkjOPQQDAgNIADBFAiEA+bFzXRoJ4PCQbhAAtpkcMjt0vNj5rEW0
lOMBGDNyaWkCIB1vmM7PcZzv/c9bIrxF5kqv6QXomouhByUfeNUTbpKW
-----END CERTIFICATE-----
…, review F1)

Plan's new create branch (no observed state, no resource id) derived a plan from
any desired config, so a config without the kind's required fields, with a field no
caller may set, or with a gross type error still produced a consistent plan that
Execute would run. The branch now applies the contract check Validate already does
(required, known, JSON kind) before it plans, and Execute, which derives the plan
again, refuses the same configs. A refusal names desired.data.<field> (validation for
a missing required field, schema for an unknown field or a wrong type) and makes no
sign or actuate call; with several problems the first by field path is named, so the
answer does not depend on map order. An update is not held to the required rule.
Subnet and instance create cases in the derivable-plan test now carry their required
fields.

---
[signing-metadata]
key = cic-my-sign-key
signature = vault:v1:MEYCIQC5h/pYGSTsJxkvsB+JDIM8hH+sqGo0nnl9shrKhRDEjAIhAJ44OObJDZciLoUn7eZFjAbDinbwyUekjfsHMlnVyxs6
hash-algorithm = sha256
digest = SwC6QhyfouXD/Yo6wj6dL6sBd3+jQqGZzM+FCkK3III=

[certificate]
-----BEGIN CERTIFICATE-----
MIICBjCCAaygAwIBAgIUSnRMR6RPnEbg296XWPOqq/u5PCwwCgYIKoZIzj0EAwIw
QzELMAkGA1UEBhMCSFUxGTAXBgNVBAoMEENlbnRyYWxJbmZyYUNvcmUxGTAXBgNV
BAMMEENJQyBEZXZlbG9wZXIgQ0EwHhcNMjYwMzIwMTMyMjU5WhcNMjYxMjMxMTMy
MjU5WjBFMQswCQYDVQQGEwJIVTEZMBcGA1UECgwQQ2VudHJhbEluZnJhQ29yZTEb
MBkGA1UEAwwSR2Fib3IgWm9sdGFuIFNpbmtvMFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEIG2CVmTfmLB9pLLclj7YmP2eedAjklpy4LGrU2ijoiy6Xqpuybv7OgJe
i+ez31s65NEV8+X/ByeX1cstR988z6N8MHowCQYDVR0TBAIwADAdBgNVHQ4EFgQU
yZN6AIX/TNnIJ9GwAa/NRN3ujHAwHwYDVR0jBBgwFoAUXn6CHYzPUqU4JVP8g+OS
WeDYjhcwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEF
BQcDBDAKBggqhkjOPQQDAgNIADBFAiEA+bFzXRoJ4PCQbhAAtpkcMjt0vNj5rEW0
lOMBGDNyaWkCIB1vmM7PcZzv/c9bIrxF5kqv6QXomouhByUfeNUTbpKW
-----END CERTIFICATE-----
@sinkog
sinkog marked this pull request as ready for review November 8, 2026 12:56
@sinkog
sinkog merged commit 7012350 into devel Nov 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant