Skip to content

feat: scaffold workflow-plugin-audit-chain (ADR 0008/0009)#1

Merged
intel352 merged 9 commits intomainfrom
feat/scaffold
May 3, 2026
Merged

feat: scaffold workflow-plugin-audit-chain (ADR 0008/0009)#1
intel352 merged 9 commits intomainfrom
feat/scaffold

Conversation

@intel352
Copy link
Copy Markdown
Contributor

@intel352 intel352 commented May 3, 2026

Summary

Implements the full scaffold for workflow-plugin-audit-chain — a new public/MIT Go plugin providing tamper-evident, hash-chained audit logging for the GoCodeAlone/workflow engine (ADR 0008). Named audit-chain per ADR 0009 to avoid collision with the existing audit plugin.

  • Plugin manifest + proto contract (plugin.json, plugin.contracts.json, proto/audit.proto, generated gen/audit.pb.go) — 7 step types, 6 module types, 1 trigger type; Entry.entry_hash preimage documented as SHA-256 of RFC 8785 canonical JSON of {event_type, ledger, payload_hash, prev_entry_hash, sequence}
  • Canonical JSON helper (chain/canonical.go) — RFC 8785 key-sorted, whitespace-free JSON using json.NewDecoder.UseNumber() for numeric precision
  • Hash + Merkle library (chain/hash.go, chain/merkle.go) — PayloadHash([]byte) (string, error), EntryHash(...), RFC 6962 §2.1 domain-separated Merkle tree (0x00 leaf / 0x01 internal prefixes), direction-prefixed inclusion proofs, VerifyInclusion with crypto/subtle.ConstantTimeCompare
  • Append protocol (chain/append.go, migrations/001–004) — Appender.Append (owns its own tx) and AppendTx (participates in caller's tx, required by BMW PR 11 Task 47); SELECT … FOR UPDATE on audit_ledgers for gap-free monotonic sequences; SET LOCAL lock_timeout='5s'; DB-side NOW() for created_at; metadata []byte column wired through
  • Integration tests (chain/append_test.go) — testcontainers-go postgres:16-alpine; 8 tests including 50-goroutine × 10-entry concurrency test verifying sequences 1..500 with no gaps under -race
  • Makefile targets: proto-gen, build, test, test-migrations, vet
  • CI/CD (.github/workflows/ci.yml, release.yml, .goreleaser.yaml) — cross-platform binaries for linux/darwin/windows × amd64/arm64

Test plan

  • GOWORK=off go build ./... — clean build
  • GOWORK=off go test ./... -v -race -count=1 — all 40 tests pass (8 integration + 32 unit)
  • make test-migrations — migrations up/down cycle passes
  • GOWORK=off go vet ./... — no issues

🤖 Generated with Claude Code

intel352 and others added 9 commits May 3, 2026 01:13
Rename all workflow-plugin-TEMPLATE placeholders to workflow-plugin-audit-chain
(per ADR 0009). Declare all 7 step types, 6 module types, and 1 trigger type
per the prereq design spec. Add proto/audit.proto with full typed contracts for
all step input/output messages (AppendRequest/Response through PublicReceiptResponse)
including PollAnchorConfirmationResponse swallowed/error_message fields. Commit
generated gen/audit.pb.go. Add Makefile with proto-gen target. All 10 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace remaining workflow-plugin-TEMPLATE placeholders in CLAUDE.md
(title, cross-compile command, cmd path). Add README.md describing all
7 step types, 6 module types, 1 trigger type, quick-start YAML, and
build instructions per task spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… improvements

Important: add actor (field 8) and metadata (field 9) to Entry message so
ProofResponse carries the full appended record matching AppendRequest fields.

Minor: add start_sequence/end_sequence to MerkleRootResponse so the covered
range is self-contained. Add canonical preimage comment to Entry.entry_hash
documenting the exact hash input (sequence, ledger, event_type, payload_hash,
prev_entry_hash, created_at — sorted keys, no whitespace). Fix variable
shadowing 't' → 'typ' in TestModuleTypes_Declared and TestStepTypes_Declared.
Add TestCreateTrigger_KnownType_ReturnsNotImplemented to match Module/Step pattern.

All 11 tests pass; go build ./... exits 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Canonicalize() uses json.Decoder with UseNumber() to decode, then
json.Marshal() to re-encode — encoding/json sorts map keys automatically,
producing RFC 8785-compliant output (keys lexicographically sorted at every
level, no whitespace, arrays preserve order). UseNumber() prevents float64
precision loss for integers > 2^53.

13 tests: sort/whitespace/nested/idempotent (required), plus array order,
deeply nested, booleans, null, empty object, large integer preservation,
objects inside arrays, invalid JSON error, and string idempotency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PayloadHash: SHA-256(Canonicalize(data)) — key-order-invariant payload hash
using the RFC 8785 canonical helper from Task 7.

EntryHash: SHA-256 of canonical JSON {event_type, ledger, payload_hash,
prev_entry_hash, sequence} — ties each entry cryptographically to its
predecessor via prev_entry_hash. created_at/actor/metadata excluded per design.

MerkleRoot: standard binary tree over []string leaves (SHA-256 each leaf,
combine pairs as SHA-256(left||right), duplicate last when odd count).

InclusionProof: returns direction-prefixed sibling path ("L"|"R" + 64 hex).
VerifyInclusion: recomputes root from leaf + proof and compares.

38 tests: all 5 required TDD cases plus determinism, adversarial tamper,
out-of-range, all-indices verification, single/two/four/seven-leaf trees.
go build ./... exits 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e compare

Important 1: add RFC 6962 §2.1 domain separation to Merkle tree.
leafNode: SHA-256(0x00 || leaf_bytes); combineNodes: SHA-256(0x01 || left || right).
Prevents an adversary from supplying a 64-byte internal-node preimage as a leaf
to forge a proof path. Test helpers in merkle_test.go updated to match.

Important 2: PayloadHash returns (string, error) instead of panicking.
gRPC does not validate proto bytes fields as JSON; a malformed payload reaching
PayloadHash would crash the append goroutine. Callers now handle the error.
Added TestPayloadHash_InvalidJSON_ReturnsError test.

Minor 3: fix TestMerkleProof_TamperedProof_Fails — previously tested invalid-hex
truncation; now flips a single nibble to a different valid hex digit using the
full-length proof, properly testing hash-mismatch rejection.

Minor 4: VerifyInclusion uses crypto/subtle.ConstantTimeCompare on raw bytes
instead of string equality for the final root comparison.

39 tests pass; go build ./... exits 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ation

Implements Append + AppendTx (BMW PR 11 Task 47 dual-entry-point contract),
4 up/down SQL migrations, testcontainer integration tests (8 cases including
50-goroutine × 10-entry concurrency test confirming gap-free sequences 1..500),
and a test-migrations Makefile target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add metadata []byte param to Append+AppendTx (fixes dead schema column;
  Task 47 can now pass metadata without a breaking change)
- Fix all unchecked Scan calls in tests (false-positive risk on rollback test
  where zero-value matched expected value)
- Add SET LOCAL lock_timeout='5s' before FOR UPDATE (surfaces stalled holders
  as errors rather than indefinite blocks)
- Use DB-side NOW() for created_at (avoids multi-node clock skew)
- Drop unused gID goroutine closure parameter; use range-over-int idiom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…il wfctl strict-contracts validator)

The wfctl plugin validate --strict-contracts CI check requires contracts
array elements to be objects (map[string]interface{}), not filename
strings. Other GoCodeAlone plugins use null or [] for empty contracts;
adopting [] to match template default. The plugin.contracts.json file
remains in-tree for the future when contracts are formalized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@intel352 intel352 merged commit 433ea17 into main May 3, 2026
3 of 4 checks passed
@intel352 intel352 deleted the feat/scaffold branch May 3, 2026 18:04
intel352 added a commit that referenced this pull request May 3, 2026
…tion) — PR 4 (#3)

* feat(modules): add audit.ledger + audit.anchor_provider.* modules

- Add dsn field to LedgerConfig proto + three new anchor provider config
  proto messages (OpenTimestampsProviderConfig, GitAnchorProviderConfig,
  SigstoreProviderConfig); regenerate gen/audit.pb.go.
- modules/ledger.go: LedgerModule typed with LedgerConfig; Init opens
  Postgres via sql.Open, creates chain.Appender, registers in ledger
  registry keyed by partition name; Stop unregisters + closes DB.
- modules/anchor_provider.go: anchorProviderModule wraps each provider;
  typed factory functions (NewOpenTimestampsProviderModule,
  NewGitAnchorProviderModule, NewSigstoreProviderModule) construct the
  underlying provider and register it on Init.
- 14 unit tests; all pass without a running Postgres instance.
- internal/plugin.go: implement TypedModuleProvider (CreateTypedModule)
  so the gRPC server uses the typed proto path — zero map[string]any at
  the module creation boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(modules): address code-review issues in Task 13

Critical:
- NewLedgerModule now validates config.Name is non-empty; silently
  registering under key "" would corrupt the ledger registry.

Important:
- Fix AuditChainPlugin doc comment: remove false TypedStepProvider claim
  (Task 14 adds that interface). Add compile-time assertion
  `var _ sdk.TypedModuleProvider = (*AuditChainPlugin)(nil)`.
- Add 5 CreateTypedModule tests (the primary gRPC path was unexercised):
  valid LedgerConfig, nil config, mismatched anypb type, deferred
  providers, unknown type.

Minor:
- TestLedgerModule_StopUnregisters: add missing t.Cleanup guard.
- LedgerModule.Init: guard against double-call to prevent DB pool leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(steps): add 7 audit-plugin step types with typed proto contracts

Implements Task 14. All step handlers use sdk.TypedStepProvider (zero
map[string]any). Key changes:

- proto/audit.proto: add ledger field (5) to PollAnchorConfirmationRequest
  so poll handlers can retrieve the right *sql.DB from the registry.
- gen/audit.pb.go: regenerated.
- modules/ledger.go: add RegisterDB/GetDB/UnregisterDB registry; Init()
  and Stop() register/unregister both the chain.Appender and *sql.DB.
- modules/anchor_provider.go: add AnchorProviderNames() for step.audit.anchor
  "all configured providers" fallback.
- steps/: new package — AppendHandler, VerifyHandler, MerkleRootHandler,
  AnchorHandler, PollAnchorConfirmationHandler (swallow-transient-errors
  contract), ProofHandler (RFC 6962 inclusion proof), PublicReceiptHandler
  (verifiable receipt JSON + SHA256 hash, optional field redaction).
- internal/plugin.go: wire TypedStepProvider; stepFactories slice maps each
  step type to its sdk.NewTypedStepFactory; legacy CreateStep path returns
  "not yet implemented via legacy path" for known types.
- internal/plugin_test.go: TestTypedStepTypes_Declared,
  TestCreateTypedStep_UnknownType_ReturnsError,
  TestCreateTypedStep_KnownType_ReturnsInstance.

All packages pass: GOWORK=off go test ./... -race -count=1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(steps): address spec-reviewer feedback on Task 14

Issue 1 — poll_anchor_confirmation transient/hard-error test coverage:
- steps/testutil_test.go: add mockAnchorProvider (controls Verify return)
  and openFakeDB / fakeSQLDriver (fake database/sql driver returning
  confirmation="pending" for any SELECT without a real Postgres instance).
- steps/poll_anchor_confirmation_test.go: add
  TestPollAnchorConfirmationHandler_TransientError_Swallowed — asserts
  swallowed=true, transitioned=false, error_message set, no gRPC error.
  Add TestPollAnchorConfirmationHandler_HardError_PropagatesGRPC — asserts
  non-nil error when provider.Verify returns a hard error.

Issue 2 — public_receipt pseudonym format and replace-not-delete:
- ApplyRedactions (now exported for direct testing): assigns
  "contributor_N" labels (N increments per unique original value within
  the receipt scope); REPLACES the field value with the pseudonym string
  instead of deleting the field; deduplicates: identical originals share
  one label.
- steps/public_receipt_test.go: add TestApplyRedactions_TwoDistinctFields,
  TestApplyRedactions_DuplicateOriginalValue, TestApplyRedactions_NoRedactFields.

Issue 3 — map[string]any in public_receipt.go:
- Replaced map[string]any receipt document and anchorRecordSlice with
  typed structs: receiptDocument, receiptEntry, receiptMerkleProof,
  receiptAnchor. Zero map[string]any in the package.

All packages pass: GOWORK=off go test ./... -race -count=1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(task14): address all 4 code-reviewer issues

- chain: Append/AppendTx return DB-assigned createdAt via RETURNING created_at;
  steps/append.go propagates DB timestamp to AppendResponse (not app clock)
- chain: update all call sites in append_test.go to 4-return destructuring
- poll_anchor_confirmation: guard against downgrade with forward-only
  confirmationOrder map (pending<confirmed<finalized)
- anchor: INSERT ... ON CONFLICT (ledger,provider,range_start,range_end) DO NOTHING
  for idempotent re-runs; matching unique index added to 004_indexes.sql + down

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(task15): add integration tests for plugin entrypoint

- TestIntegration_BinaryBuilds: shells out to go build to verify the
  cmd/workflow-plugin-audit-chain binary compiles and links end-to-end
- TestIntegration_AllStepsAreTyped: creates all 7 step types and verifies
  Execute() returns the typed-step dispatch error, proving each is a
  TypedStepInstance (not a legacy step)
- TestIntegration_LedgerModuleLifecycle: exercises the full Init→Start→Stop
  lifecycle, verifying ledger+DB registries are populated after Init and
  cleared after Stop; also verifies double-Init guard
- TestIntegration_AnchorProviderModule_OpenTimestamps: lifecycle smoke test
  for the OTS anchor provider module (no network calls on Init)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(task15): root-level E2E integration test (append-5, verify, Merkle, proof)

integration_test.go at the module root exercises the full audit-chain
scenario through the plugin factory chain:

1. Spins up ephemeral Postgres 16 via testcontainers + applies all 4
   migrations.
2. Declares audit.ledger via internal.NewPlugin().CreateTypedModule —
   exercises the typed module factory wiring in internal/plugin.go.
3. Appends 5 entries via step.audit.append; verifies sequence 1-5 and
   64-char hashes.
4. Verifies chain integrity over all 5 entries via step.audit.verify;
   expects Valid=true, EntriesVerified=5.
5. Computes Merkle root over entries 1-5 via step.audit.merkle_root;
   cross-checks against independently computed chain.MerkleRoot.
6. Records a mock audit_anchors row for range 1-5 with the computed root.
7. Retrieves and cryptographically verifies the Merkle inclusion proof for
   entry 3 via step.audit.proof + chain.VerifyInclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(task15): rewrite E2E integration test to use gRPC subprocess transport

Addresses all 3 spec-reviewer rejection issues:

1. Delete internal/integration_test.go (wrong location — was internal package,
   not repo root).

2. Root integration_test.go now includes the full audit chain scenario:
   append-5, verify, Merkle root, cross-check, mock anchor, inclusion proof
   for entry 3, and chain.VerifyInclusion cryptographic check.

3. All step executions go through real gRPC proto serialisation:
   - testGRPCPlugin wraps go-plugin and dispenses pb.PluginServiceClient
     directly (bypassing *ext.PluginClient's unexported fields).
   - buildBinary/startPlugin compile the actual binary and start it as a
     subprocess via goplugin.NewClient + ext.Handshake.
   - Each request is packed as anypb.Any, sent over TCP gRPC to the subprocess,
     and unpacked from the typed response — genuine wire serialisation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(task15): address code-reviewer Important #1, #3 and Minor #4

- Important #1: add testing.Short() guard at top of TestE2E_AuditChainScenario
  so Docker-dependent test skips cleanly under `go test -short ./...`; remove
  redundant guard from buildBinary (test-level guard fires first).
- Important #3: log StopModule errors in t.Cleanup instead of discarding with
  blank identifier.
- Minor #4: run `GOWORK=off go mod tidy` — promotes testcontainers-go and
  testcontainers-go/modules/postgres from // indirect to direct deps.

Important #2 (direct step handler calls) was already resolved in the prior
commit (1aa6765) which rewrote the test to use goplugin.NewClient subprocess
+ pb.PluginServiceClient gRPC calls. Code-reviewer reviewed an earlier commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: strict-contracts — add kind/mode fields, drop unimplemented providers

plugin.contracts.json: rewrote all 12 descriptors with required "kind",
"type", and "mode": "strict_proto" fields (previously all had neither
"kind" nor "mode", causing the wfctl validator to skip every descriptor
and report 14 coverage-gap errors).

plugin.json: remove audit.anchor_provider.ethereum and
audit.anchor_provider.aws_qldb from capabilities.moduleTypes — those
providers are not implemented and must not appear in the advertised
contract surface.

Validator now passes: OK workflow-plugin-audit-chain v0.1.0 (plugin.json)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: correct trigger output proto type AuditEntry → Entry

The trigger.audit.entry_appended contract referenced a non-existent
proto type workflow.plugin.audit.v1.AuditEntry. The actual message is
workflow.plugin.audit.v1.Entry (proto/audit.proto line 242).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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