From 4cc472d5c2e711769c11139b5c31061ee55a98c6 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 19:48:16 -0400 Subject: [PATCH 01/10] feat: validate subjectId as a safe NATS subject token Implement validateSubjectID() helper to ensure subjectId contains only characters safe for NATS subject tokens (alphanumerics, dash, underscore). Rejects empty strings and any NATS special characters (., *, >). Wire validation into NewEvidenceIngestedEvent to catch invalid subjectIds at construction time, preventing silent collision with NATS wildcard bindings downstream. This function signature is reused by NewEvidenceSealedEvent (Task 3) and NewEvidenceQuarantinedEvent (Task 4). Assisted-by: Claude Code --- events/events.go | 24 ++++++++++++++++++++++++ events/events_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/events/events.go b/events/events.go index 41d7454..31c5c3b 100644 --- a/events/events.go +++ b/events/events.go @@ -8,6 +8,8 @@ package events import ( "errors" + "fmt" + "regexp" "time" cloudevents "github.com/cloudevents/sdk-go/v2" @@ -15,6 +17,25 @@ import ( "github.com/google/uuid" ) +// subjectIDPattern restricts subjectId to characters that are safe as a +// literal NATS subject token: alphanumerics, dash, and underscore. It +// excludes ".", "*", and ">" — the NATS token separator and wildcards — so +// a subjectId can never reshape or collide with a subscriber's wildcard +// binding (e.g. "core.evidence.ingested.*"). +var subjectIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// validateSubjectID returns an error if subjectID is empty or contains any +// character unsafe for use as a literal NATS subject token. +func validateSubjectID(subjectID string) error { + if subjectID == "" { + return errors.New("subjectId must not be empty") + } + if !subjectIDPattern.MatchString(subjectID) { + return fmt.Errorf("subjectId %q must match %s", subjectID, subjectIDPattern.String()) + } + return nil +} + const TypeEvidenceIngested = "dev.complytime.evidence.ingested" // EvidenceIngestedData is the CloudEvents data payload for @@ -39,6 +60,9 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) if subject == "" { return cloudevents.Event{}, errors.New("subject must not be empty") } + if err := validateSubjectID(data.SubjectID); err != nil { + return cloudevents.Event{}, err + } e := event.New(cloudevents.VersionV1) e.SetID(uuid.New().String()) diff --git a/events/events_test.go b/events/events_test.go index 197e8c9..15beff2 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -227,6 +227,43 @@ func TestNewEvidenceIngestedEventWireFormatRoundTrip(t *testing.T) { } } +func TestNewEvidenceIngestedEventInvalidSubjectID(t *testing.T) { + cases := []struct { + name string + subjectID string + }{ + {"contains dot", "my-app.v1"}, + {"contains star wildcard", "my-app-*"}, + {"contains gt wildcard", "my-app->"}, + {"contains space", "my app"}, + {"empty", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: tc.subjectID, + } + _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data) + if err == nil { + t.Errorf("expected error for subjectId %q", tc.subjectID) + } + }) + } +} + +func TestNewEvidenceIngestedEventValidSubjectIDCharset(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app_v1-2", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err != nil { + t.Errorf("unexpected error for valid subjectId: %v", err) + } +} + func TestExamplePayloads_ConformToSchema(t *testing.T) { examplesDir := filepath.Join("..", "api", "events", "examples") files, err := filepath.Glob(filepath.Join(examplesDir, "*.json")) From da4216b6e301e75d30727a0bd4402d4b10c9cec5 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 20:29:17 -0400 Subject: [PATCH 02/10] fix: make storageRef required and URI-scheme-prefixed on evidence.ingested Consumers cannot fetch the evidence artifact from an ingested event without a storage pointer, so storageRef is now required rather than optional. Its previous description ("Internal storage reference") was inaccurate -- it documents a public field, not an internal-only one. storageRef must also carry a URI-style scheme prefix (e.g. s3://, gcp://, locker://) so consumers can dispatch to the correct storage backend. The set of valid backends isn't fixed, so validation checks RFC 3986 scheme shape rather than an enumerated allowlist. --- api/events/asyncapi.yaml | 3 +- .../examples/evidence-ingested-minimal.json | 1 + .../evidence-ingested-with-shard.json | 2 +- api/events/examples/evidence-ingested.json | 2 +- .../schemas/EvidenceIngestedData.schema.json | 3 +- events/events.go | 14 +++++++- events/events_test.go | 33 ++++++++++++++++--- 7 files changed, 49 insertions(+), 9 deletions(-) diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index fd5a5ac..20e0697 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -97,6 +97,7 @@ components: required: - contentDigest - artifactType + - storageRef - subjectId properties: artifactType: @@ -110,7 +111,7 @@ components: description: Subject shard identifier (null when sharding is not configured) storageRef: type: string - description: Internal storage reference + description: URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://) subjectId: type: string description: Compliance subject identifier diff --git a/api/events/examples/evidence-ingested-minimal.json b/api/events/examples/evidence-ingested-minimal.json index 32c2eee..fad8154 100644 --- a/api/events/examples/evidence-ingested-minimal.json +++ b/api/events/examples/evidence-ingested-minimal.json @@ -9,6 +9,7 @@ "data": { "contentDigest": "sha256:a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678", "artifactType": "application/vnd.gemara.evaluation-log+json", + "storageRef": "locker://store/evidence/2026/08/20/minimal01", "subjectId": "my-app-v1" } } diff --git a/api/events/examples/evidence-ingested-with-shard.json b/api/events/examples/evidence-ingested-with-shard.json index d1a5fe6..b0e5823 100644 --- a/api/events/examples/evidence-ingested-with-shard.json +++ b/api/events/examples/evidence-ingested-with-shard.json @@ -9,7 +9,7 @@ "data": { "contentDigest": "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", "artifactType": "application/vnd.gemara.evaluation-log+json", - "storageRef": "store/evidence/2026/08/20/c9a1e2b3", + "storageRef": "locker://store/evidence/2026/08/20/c9a1e2b3", "subjectId": "my-app-v1", "shardId": "shard-west-1" } diff --git a/api/events/examples/evidence-ingested.json b/api/events/examples/evidence-ingested.json index 1fb8862..5531df1 100644 --- a/api/events/examples/evidence-ingested.json +++ b/api/events/examples/evidence-ingested.json @@ -9,7 +9,7 @@ "data": { "contentDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "artifactType": "application/vnd.gemara.evaluation-log+json", - "storageRef": "store/evidence/2026/08/20/a1b2c3d4", + "storageRef": "locker://store/evidence/2026/08/20/a1b2c3d4", "subjectId": "my-app-v1" } } diff --git a/api/events/schemas/EvidenceIngestedData.schema.json b/api/events/schemas/EvidenceIngestedData.schema.json index 309b1bc..7ceb23b 100644 --- a/api/events/schemas/EvidenceIngestedData.schema.json +++ b/api/events/schemas/EvidenceIngestedData.schema.json @@ -17,7 +17,7 @@ "type": "string" }, "storageRef": { - "description": "Internal storage reference", + "description": "URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)", "type": "string" }, "subjectId": { @@ -28,6 +28,7 @@ "required": [ "contentDigest", "artifactType", + "storageRef", "subjectId" ], "type": "object" diff --git a/events/events.go b/events/events.go index 31c5c3b..c27acf6 100644 --- a/events/events.go +++ b/events/events.go @@ -24,6 +24,12 @@ import ( // binding (e.g. "core.evidence.ingested.*"). var subjectIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +// storageRefPattern requires storageRef to carry a URI-style scheme prefix +// (e.g. "s3://", "gcp://", "locker://") per RFC 3986 scheme syntax. The set +// of valid backends is not fixed, so this validates general shape rather +// than an enumerated allowlist. +var storageRefPattern = regexp.MustCompile(`^[a-z][a-z0-9+.-]*://`) + // validateSubjectID returns an error if subjectID is empty or contains any // character unsafe for use as a literal NATS subject token. func validateSubjectID(subjectID string) error { @@ -46,7 +52,7 @@ type EvidenceIngestedData struct { ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` - StorageRef string `json:"storageRef,omitempty" asyncapi-field:"description:Internal storage reference"` + StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)"` SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` ShardID *string `json:"shardId,omitempty" asyncapi-field:"description:Subject shard identifier (null when sharding is not configured)"` } @@ -63,6 +69,12 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) if err := validateSubjectID(data.SubjectID); err != nil { return cloudevents.Event{}, err } + if data.StorageRef == "" { + return cloudevents.Event{}, errors.New("storageRef must not be empty") + } + if !storageRefPattern.MatchString(data.StorageRef) { + return cloudevents.Event{}, fmt.Errorf("storageRef %q must have a URI scheme prefix (e.g. s3://, gcp://, locker://)", data.StorageRef) + } e := event.New(cloudevents.VersionV1) e.SetID(uuid.New().String()) diff --git a/events/events_test.go b/events/events_test.go index 15beff2..84496e1 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -66,9 +66,6 @@ func TestEvidenceIngestedDataJSONOmitsOptionalFields(t *testing.T) { t.Fatalf("Unmarshal to map: %v", err) } - if _, ok := raw["storageRef"]; ok { - t.Error("storageRef should be omitted when empty") - } if _, ok := raw["shardId"]; ok { t.Error("shardId should be omitted when nil") } @@ -102,6 +99,7 @@ func TestNewEvidenceIngestedEvent(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "locker://ref/123", SubjectID: "my-app-v1", } @@ -160,6 +158,29 @@ func TestNewEvidenceIngestedEventEmptySource(t *testing.T) { } } +func TestNewEvidenceIngestedEventEmptyStorageRef(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for empty storageRef") + } +} + +func TestNewEvidenceIngestedEventStorageRefMissingScheme(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "store/evidence/2026/08/20/a1b2c3d4", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for storageRef missing URI scheme prefix") + } +} + func TestNewEvidenceIngestedEventEmptySubject(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", @@ -177,7 +198,7 @@ func TestNewEvidenceIngestedEventWireFormatRoundTrip(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", ArtifactType: "application/vnd.gemara.evaluation-log+json", - StorageRef: "ref/456", + StorageRef: "locker://ref/456", SubjectID: "my-app-v1", } @@ -257,6 +278,7 @@ func TestNewEvidenceIngestedEventValidSubjectIDCharset(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "locker://ref/123", SubjectID: "my-app_v1-2", } if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err != nil { @@ -331,6 +353,9 @@ func TestExamplePayloads_ConformToSchema(t *testing.T) { if envelope.Data.SubjectID == "" { t.Error("data.subjectId must not be empty") } + if envelope.Data.StorageRef == "" { + t.Error("data.storageRef must not be empty") + } }) } } From 87a9e82e8912541028fdcd857e7b0cca0c41953e Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 20:50:13 -0400 Subject: [PATCH 03/10] feat: add dev.complytime.evidence.sealed and .quarantined event types Workers need a way to signal that ingested evidence has been validated and sealed into a unit of work, or that validation failed and the artifact was quarantined. Per the consumer contract in docs/versioning.md (dispatch on CloudEvents type, never overload one type with a status flag), these outcomes get their own types and example payloads rather than a field on EvidenceIngestedData. TestExamplePayloads_ConformToSchema now dispatches on envelope.type to validate each example against its own data struct instead of forcing every example through EvidenceIngestedData. --- api/events/asyncapi.yaml | 181 ++++++++++++++++++ api/events/examples/evidence-quarantined.json | 15 ++ api/events/examples/evidence-sealed.json | 14 ++ .../EvidenceQuarantinedCloudEvent.schema.json | 50 +++++ .../EvidenceQuarantinedData.schema.json | 35 ++++ .../EvidenceSealedCloudEvent.schema.json | 50 +++++ .../schemas/EvidenceSealedData.schema.json | 30 +++ events/events.go | 88 +++++++++ events/events_test.go | 154 ++++++++++++--- 9 files changed, 590 insertions(+), 27 deletions(-) create mode 100644 api/events/examples/evidence-quarantined.json create mode 100644 api/events/examples/evidence-sealed.json create mode 100644 api/events/schemas/EvidenceQuarantinedCloudEvent.schema.json create mode 100644 api/events/schemas/EvidenceQuarantinedData.schema.json create mode 100644 api/events/schemas/EvidenceSealedCloudEvent.schema.json create mode 100644 api/events/schemas/EvidenceSealedData.schema.json diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index 20e0697..df68fe8 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -29,12 +29,40 @@ channels: messages: EvidenceIngested: $ref: '#/components/messages/EvidenceIngested' + evidenceQuarantined: + address: core.evidence.quarantined.{subjectId} + description: Evidence quarantine pipeline for compliance artifacts + parameters: + subjectId: + description: The compliance subject identifier + messages: + EvidenceQuarantined: + $ref: '#/components/messages/EvidenceQuarantined' + evidenceSealed: + address: core.evidence.sealed.{subjectId} + description: Evidence sealing pipeline for compliance artifacts + parameters: + subjectId: + description: The compliance subject identifier + messages: + EvidenceSealed: + $ref: '#/components/messages/EvidenceSealed' operations: consumeEvidenceIngested: action: receive summary: Consume evidence-ingested events channel: $ref: '#/channels/evidenceIngested' + consumeEvidenceQuarantined: + action: receive + summary: Consume evidence-quarantined events + channel: + $ref: '#/channels/evidenceQuarantined' + consumeEvidenceSealed: + action: receive + summary: Consume evidence-sealed events + channel: + $ref: '#/channels/evidenceSealed' publishEvidenceIngested: action: send summary: Published when evidence is accepted for processing; before sealing @@ -44,6 +72,24 @@ operations: nats: x-stream: EVIDENCE bindingVersion: 0.1.0 + publishEvidenceQuarantined: + action: send + summary: Published when a worker fails to validate evidence and quarantines it + channel: + $ref: '#/channels/evidenceQuarantined' + bindings: + nats: + x-stream: EVIDENCE + bindingVersion: 0.1.0 + publishEvidenceSealed: + action: send + summary: Published when a worker validates and seals evidence into a unit of work + channel: + $ref: '#/channels/evidenceSealed' + bindings: + nats: + x-stream: EVIDENCE + bindingVersion: 0.1.0 components: messages: EvidenceIngested: @@ -52,6 +98,18 @@ components: contentType: application/cloudevents+json payload: $ref: '#/components/schemas/EvidenceIngestedCloudEvent' + EvidenceQuarantined: + name: EvidenceQuarantined + title: Evidence Quarantined + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/EvidenceQuarantinedCloudEvent' + EvidenceSealed: + name: EvidenceSealed + title: Evidence Sealed + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/EvidenceSealedCloudEvent' schemas: EvidenceIngestedCloudEvent: type: object @@ -115,3 +173,126 @@ components: subjectId: type: string description: Compliance subject identifier + EvidenceQuarantinedCloudEvent: + type: object + description: CloudEvents v1.0 envelope for dev.complytime.evidence.quarantined + required: + - specversion + - id + - type + - source + - subject + - time + - datacontenttype + - data + properties: + data: + $ref: '#/components/schemas/EvidenceQuarantinedData' + datacontenttype: + type: string + const: application/json + id: + type: string + format: uuid + source: + type: string + description: URI identifying the producing service + specversion: + type: string + const: "1.0" + subject: + type: string + description: The compliance subject identifier + time: + type: string + format: date-time + type: + type: string + const: dev.complytime.evidence.quarantined + EvidenceQuarantinedData: + type: object + description: |- + EvidenceQuarantinedData is the CloudEvents data payload for + evidence.quarantined events, published when a worker fails to validate + an ingested artifact. + required: + - contentDigest + - artifactType + - subjectId + - reason + properties: + artifactType: + type: string + description: Gemara artifact type + contentDigest: + type: string + description: SHA-256 digest of the evidence artifact + reason: + type: string + description: Why validation failed + shardId: + type: string + description: Subject shard identifier (null when sharding is not configured) + subjectId: + type: string + description: Compliance subject identifier + EvidenceSealedCloudEvent: + type: object + description: CloudEvents v1.0 envelope for dev.complytime.evidence.sealed + required: + - specversion + - id + - type + - source + - subject + - time + - datacontenttype + - data + properties: + data: + $ref: '#/components/schemas/EvidenceSealedData' + datacontenttype: + type: string + const: application/json + id: + type: string + format: uuid + source: + type: string + description: URI identifying the producing service + specversion: + type: string + const: "1.0" + subject: + type: string + description: The compliance subject identifier + time: + type: string + format: date-time + type: + type: string + const: dev.complytime.evidence.sealed + EvidenceSealedData: + type: object + description: |- + EvidenceSealedData is the CloudEvents data payload for evidence.sealed + events. It carries the same evidence identity as EvidenceIngestedData + but signals that a worker has validated the artifact and sealed it into + a unit of work. + required: + - contentDigest + - artifactType + - subjectId + properties: + artifactType: + type: string + description: Gemara artifact type + contentDigest: + type: string + description: SHA-256 digest of the evidence artifact + shardId: + type: string + description: Subject shard identifier (null when sharding is not configured) + subjectId: + type: string + description: Compliance subject identifier diff --git a/api/events/examples/evidence-quarantined.json b/api/events/examples/evidence-quarantined.json new file mode 100644 index 0000000..869f2db --- /dev/null +++ b/api/events/examples/evidence-quarantined.json @@ -0,0 +1,15 @@ +{ + "specversion": "1.0", + "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", + "type": "dev.complytime.evidence.quarantined", + "source": "complytime-worker", + "subject": "my-app-v1", + "time": "2026-08-20T14:32:00Z", + "datacontenttype": "application/json", + "data": { + "contentDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "artifactType": "application/vnd.gemara.evaluation-log+json", + "subjectId": "my-app-v1", + "reason": "content digest mismatch" + } +} diff --git a/api/events/examples/evidence-sealed.json b/api/events/examples/evidence-sealed.json new file mode 100644 index 0000000..cc7efa7 --- /dev/null +++ b/api/events/examples/evidence-sealed.json @@ -0,0 +1,14 @@ +{ + "specversion": "1.0", + "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "type": "dev.complytime.evidence.sealed", + "source": "complytime-worker", + "subject": "my-app-v1", + "time": "2026-08-20T14:31:00Z", + "datacontenttype": "application/json", + "data": { + "contentDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "artifactType": "application/vnd.gemara.evaluation-log+json", + "subjectId": "my-app-v1" + } +} diff --git a/api/events/schemas/EvidenceQuarantinedCloudEvent.schema.json b/api/events/schemas/EvidenceQuarantinedCloudEvent.schema.json new file mode 100644 index 0000000..d798ea7 --- /dev/null +++ b/api/events/schemas/EvidenceQuarantinedCloudEvent.schema.json @@ -0,0 +1,50 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceQuarantinedCloudEvent.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "CloudEvents v1.0 envelope for dev.complytime.evidence.quarantined", + "properties": { + "data": { + "$ref": "EvidenceQuarantinedData.schema.json" + }, + "datacontenttype": { + "const": "application/json", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "source": { + "description": "URI identifying the producing service", + "type": "string" + }, + "specversion": { + "const": "1.0", + "type": "string" + }, + "subject": { + "description": "The compliance subject identifier", + "type": "string" + }, + "time": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "dev.complytime.evidence.quarantined", + "type": "string" + } + }, + "required": [ + "specversion", + "id", + "type", + "source", + "subject", + "time", + "datacontenttype", + "data" + ], + "type": "object" +} diff --git a/api/events/schemas/EvidenceQuarantinedData.schema.json b/api/events/schemas/EvidenceQuarantinedData.schema.json new file mode 100644 index 0000000..c910b58 --- /dev/null +++ b/api/events/schemas/EvidenceQuarantinedData.schema.json @@ -0,0 +1,35 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceQuarantinedData.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "EvidenceQuarantinedData is the CloudEvents data payload for\nevidence.quarantined events, published when a worker fails to validate\nan ingested artifact.", + "properties": { + "artifactType": { + "description": "Gemara artifact type", + "type": "string" + }, + "contentDigest": { + "description": "SHA-256 digest of the evidence artifact", + "type": "string" + }, + "reason": { + "description": "Why validation failed", + "type": "string" + }, + "shardId": { + "description": "Subject shard identifier (null when sharding is not configured)", + "type": "string" + }, + "subjectId": { + "description": "Compliance subject identifier", + "type": "string" + } + }, + "required": [ + "contentDigest", + "artifactType", + "subjectId", + "reason" + ], + "type": "object" +} diff --git a/api/events/schemas/EvidenceSealedCloudEvent.schema.json b/api/events/schemas/EvidenceSealedCloudEvent.schema.json new file mode 100644 index 0000000..fc4914b --- /dev/null +++ b/api/events/schemas/EvidenceSealedCloudEvent.schema.json @@ -0,0 +1,50 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceSealedCloudEvent.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "CloudEvents v1.0 envelope for dev.complytime.evidence.sealed", + "properties": { + "data": { + "$ref": "EvidenceSealedData.schema.json" + }, + "datacontenttype": { + "const": "application/json", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "source": { + "description": "URI identifying the producing service", + "type": "string" + }, + "specversion": { + "const": "1.0", + "type": "string" + }, + "subject": { + "description": "The compliance subject identifier", + "type": "string" + }, + "time": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "dev.complytime.evidence.sealed", + "type": "string" + } + }, + "required": [ + "specversion", + "id", + "type", + "source", + "subject", + "time", + "datacontenttype", + "data" + ], + "type": "object" +} diff --git a/api/events/schemas/EvidenceSealedData.schema.json b/api/events/schemas/EvidenceSealedData.schema.json new file mode 100644 index 0000000..fc93b5e --- /dev/null +++ b/api/events/schemas/EvidenceSealedData.schema.json @@ -0,0 +1,30 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceSealedData.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "EvidenceSealedData is the CloudEvents data payload for evidence.sealed\nevents. It carries the same evidence identity as EvidenceIngestedData\nbut signals that a worker has validated the artifact and sealed it into\na unit of work.", + "properties": { + "artifactType": { + "description": "Gemara artifact type", + "type": "string" + }, + "contentDigest": { + "description": "SHA-256 digest of the evidence artifact", + "type": "string" + }, + "shardId": { + "description": "Subject shard identifier (null when sharding is not configured)", + "type": "string" + }, + "subjectId": { + "description": "Compliance subject identifier", + "type": "string" + } + }, + "required": [ + "contentDigest", + "artifactType", + "subjectId" + ], + "type": "object" +} diff --git a/events/events.go b/events/events.go index c27acf6..3776592 100644 --- a/events/events.go +++ b/events/events.go @@ -88,3 +88,91 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) } return e, nil } + +const TypeEvidenceSealed = "dev.complytime.evidence.sealed" + +// EvidenceSealedData is the CloudEvents data payload for evidence.sealed +// events. It carries the same evidence identity as EvidenceIngestedData +// but signals that a worker has validated the artifact and sealed it into +// a unit of work. +type EvidenceSealedData struct { + //nolint:unused + _ struct{} `asyncapi:"channel:core.evidence.sealed.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.sealed,send:Published when a worker validates and seals evidence into a unit of work,receive:Consume evidence-sealed events,description:Evidence sealing pipeline for compliance artifacts"` + + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` + ShardID *string `json:"shardId,omitempty" asyncapi-field:"description:Subject shard identifier (null when sharding is not configured)"` +} + +// NewEvidenceSealedEvent constructs a CloudEvents v1.0 event with the +// given source, subject, and data payload for a sealed evidence outcome. +func NewEvidenceSealedEvent(source, subject string, data EvidenceSealedData) (cloudevents.Event, error) { + if source == "" { + return cloudevents.Event{}, errors.New("source must not be empty") + } + if subject == "" { + return cloudevents.Event{}, errors.New("subject must not be empty") + } + if err := validateSubjectID(data.SubjectID); err != nil { + return cloudevents.Event{}, err + } + + e := event.New(cloudevents.VersionV1) + e.SetID(uuid.New().String()) + e.SetType(TypeEvidenceSealed) + e.SetSource(source) + e.SetSubject(subject) + e.SetTime(time.Now()) + e.SetDataContentType("application/json") + if err := e.SetData(cloudevents.ApplicationJSON, data); err != nil { + return cloudevents.Event{}, err + } + return e, nil +} + +const TypeEvidenceQuarantined = "dev.complytime.evidence.quarantined" + +// EvidenceQuarantinedData is the CloudEvents data payload for +// evidence.quarantined events, published when a worker fails to validate +// an ingested artifact. +type EvidenceQuarantinedData struct { + //nolint:unused + _ struct{} `asyncapi:"channel:core.evidence.quarantined.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.quarantined,send:Published when a worker fails to validate evidence and quarantines it,receive:Consume evidence-quarantined events,description:Evidence quarantine pipeline for compliance artifacts"` + + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` + ShardID *string `json:"shardId,omitempty" asyncapi-field:"description:Subject shard identifier (null when sharding is not configured)"` + Reason string `json:"reason" asyncapi-field:"description:Why validation failed"` +} + +// NewEvidenceQuarantinedEvent constructs a CloudEvents v1.0 event with the +// given source, subject, and data payload for a quarantined evidence +// outcome. data.Reason must not be empty. +func NewEvidenceQuarantinedEvent(source, subject string, data EvidenceQuarantinedData) (cloudevents.Event, error) { + if source == "" { + return cloudevents.Event{}, errors.New("source must not be empty") + } + if subject == "" { + return cloudevents.Event{}, errors.New("subject must not be empty") + } + if err := validateSubjectID(data.SubjectID); err != nil { + return cloudevents.Event{}, err + } + if data.Reason == "" { + return cloudevents.Event{}, errors.New("reason must not be empty") + } + + e := event.New(cloudevents.VersionV1) + e.SetID(uuid.New().String()) + e.SetType(TypeEvidenceQuarantined) + e.SetSource(source) + e.SetSubject(subject) + e.SetTime(time.Now()) + e.SetDataContentType("application/json") + if err := e.SetData(cloudevents.ApplicationJSON, data); err != nil { + return cloudevents.Event{}, err + } + return e, nil +} diff --git a/events/events_test.go b/events/events_test.go index 84496e1..4909a72 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -286,6 +286,83 @@ func TestNewEvidenceIngestedEventValidSubjectIDCharset(t *testing.T) { } } +func TestNewEvidenceSealedEvent(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + } + + e, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data) + if err != nil { + t.Fatalf("NewEvidenceSealedEvent: %v", err) + } + + if e.Type() != TypeEvidenceSealed { + t.Errorf("Type() = %q, want %q", e.Type(), TypeEvidenceSealed) + } + if e.Subject() != "my-app-v1" { + t.Errorf("Subject() = %q, want %q", e.Subject(), "my-app-v1") + } + + var got EvidenceSealedData + if err := e.DataAs(&got); err != nil { + t.Fatalf("DataAs: %v", err) + } + if got.SubjectID != data.SubjectID { + t.Errorf("data.SubjectID = %q, want %q", got.SubjectID, data.SubjectID) + } +} + +func TestNewEvidenceSealedEventInvalidSubjectID(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app.v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for invalid subjectId") + } +} + +func TestNewEvidenceQuarantinedEvent(t *testing.T) { + data := EvidenceQuarantinedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + Reason: "content digest mismatch", + } + + e, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data) + if err != nil { + t.Fatalf("NewEvidenceQuarantinedEvent: %v", err) + } + + if e.Type() != TypeEvidenceQuarantined { + t.Errorf("Type() = %q, want %q", e.Type(), TypeEvidenceQuarantined) + } + + var got EvidenceQuarantinedData + if err := e.DataAs(&got); err != nil { + t.Fatalf("DataAs: %v", err) + } + if got.Reason != data.Reason { + t.Errorf("data.Reason = %q, want %q", got.Reason, data.Reason) + } +} + +func TestNewEvidenceQuarantinedEventEmptyReason(t *testing.T) { + data := EvidenceQuarantinedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + Reason: "", + } + if _, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty reason") + } +} + func TestExamplePayloads_ConformToSchema(t *testing.T) { examplesDir := filepath.Join("..", "api", "events", "examples") files, err := filepath.Glob(filepath.Join(examplesDir, "*.json")) @@ -303,33 +380,26 @@ func TestExamplePayloads_ConformToSchema(t *testing.T) { t.Fatalf("read %s: %v", path, err) } - // Validate the full CloudEvents envelope deserializes. var envelope struct { - SpecVersion string `json:"specversion"` - ID string `json:"id"` - Type string `json:"type"` - Source string `json:"source"` - Subject string `json:"subject"` - Time string `json:"time"` - DataContentType string `json:"datacontenttype"` - Data EvidenceIngestedData `json:"data"` + SpecVersion string `json:"specversion"` + ID string `json:"id"` + Type string `json:"type"` + Source string `json:"source"` + Subject string `json:"subject"` + Time string `json:"time"` + DataContentType string `json:"datacontenttype"` + Data json.RawMessage `json:"data"` } if err := json.Unmarshal(b, &envelope); err != nil { t.Fatalf("unmarshal: %v", err) } - // CloudEvents envelope const values. if envelope.SpecVersion != "1.0" { t.Errorf("specversion = %q, want %q", envelope.SpecVersion, "1.0") } - if envelope.Type != TypeEvidenceIngested { - t.Errorf("type = %q, want %q", envelope.Type, TypeEvidenceIngested) - } if envelope.DataContentType != "application/json" { t.Errorf("datacontenttype = %q, want %q", envelope.DataContentType, "application/json") } - - // Required envelope fields must be non-empty. if envelope.ID == "" { t.Error("id must not be empty") } @@ -343,18 +413,48 @@ func TestExamplePayloads_ConformToSchema(t *testing.T) { t.Error("time must not be empty") } - // Required data fields must be non-empty. - if envelope.Data.ContentDigest == "" { - t.Error("data.contentDigest must not be empty") - } - if envelope.Data.ArtifactType == "" { - t.Error("data.artifactType must not be empty") - } - if envelope.Data.SubjectID == "" { - t.Error("data.subjectId must not be empty") - } - if envelope.Data.StorageRef == "" { - t.Error("data.storageRef must not be empty") + switch envelope.Type { + case TypeEvidenceIngested: + var data EvidenceIngestedData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + if data.ContentDigest == "" { + t.Error("data.contentDigest must not be empty") + } + if data.ArtifactType == "" { + t.Error("data.artifactType must not be empty") + } + if data.SubjectID == "" { + t.Error("data.subjectId must not be empty") + } + case TypeEvidenceSealed: + var data EvidenceSealedData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + if data.ContentDigest == "" { + t.Error("data.contentDigest must not be empty") + } + if data.ArtifactType == "" { + t.Error("data.artifactType must not be empty") + } + if data.SubjectID == "" { + t.Error("data.subjectId must not be empty") + } + case TypeEvidenceQuarantined: + var data EvidenceQuarantinedData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + if data.SubjectID == "" { + t.Error("data.subjectId must not be empty") + } + if data.Reason == "" { + t.Error("data.reason must not be empty") + } + default: + t.Fatalf("unhandled example type %q — add a case to this switch", envelope.Type) } }) } From 0aa5e81548baaedd7c42bf886801825a708e341b Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 21:02:26 -0400 Subject: [PATCH 04/10] fix: restore storageRef non-empty check in ConformToSchema test The TypeEvidenceIngested case of TestExamplePayloads_ConformToSchema dropped the data.StorageRef non-empty assertion during the switch-based rewrite. Task 2 made StorageRef required on EvidenceIngestedData, so this example-conformance test should still assert it. --- events/events_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/events/events_test.go b/events/events_test.go index 4909a72..1355e8f 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -428,6 +428,9 @@ func TestExamplePayloads_ConformToSchema(t *testing.T) { if data.SubjectID == "" { t.Error("data.subjectId must not be empty") } + if data.StorageRef == "" { + t.Error("data.storageRef must not be empty") + } case TypeEvidenceSealed: var data EvidenceSealedData if err := json.Unmarshal(envelope.Data, &data); err != nil { From 0e205c0a9b91511df1d09b5a41b6e6db5b83c1d1 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 21:08:14 -0400 Subject: [PATCH 05/10] docs: fix subject hierarchy description, document new event types, bump contract to 0.2.0 --- README.md | 6 +++++- api/events/asyncapi.yaml | 2 +- docs/versioning.md | 19 +++++++++++++++---- events/events.go | 2 +- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a094ab9..5618f21 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ func main() { | Type | Constant | Description | |------|----------|-------------| -| `dev.complytime.evidence.ingested` | `events.TypeEvidenceIngested` | Evidence accepted for processing | +| `dev.complytime.evidence.ingested` | `events.TypeEvidenceIngested` | Evidence accepted for processing, before validation | +| `dev.complytime.evidence.sealed` | `events.TypeEvidenceSealed` | Evidence validated and sealed into a unit of work | +| `dev.complytime.evidence.quarantined` | `events.TypeEvidenceQuarantined` | Evidence failed validation and was quarantined | ### Payload Examples @@ -58,6 +60,8 @@ Example CloudEvents JSON payloads are in | [`evidence-ingested.json`](api/events/examples/evidence-ingested.json) | Common payload with required fields and `storageRef` | | [`evidence-ingested-minimal.json`](api/events/examples/evidence-ingested-minimal.json) | Required data fields only (no optional fields) | | [`evidence-ingested-with-shard.json`](api/events/examples/evidence-ingested-with-shard.json) | All fields including optional `shardId` | +| [`evidence-sealed.json`](api/events/examples/evidence-sealed.json) | Sealed outcome after successful validation | +| [`evidence-quarantined.json`](api/events/examples/evidence-quarantined.json) | Quarantined outcome after failed validation, with `reason` | These examples conform to the [JSON Schema](api/events/schemas/) and [AsyncAPI spec](api/events/asyncapi.yaml). They are hand-maintained diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index df68fe8..02bc7ee 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -2,7 +2,7 @@ asyncapi: 3.0.0 info: title: ComplyTime API Events - version: 0.1.0 + version: 0.2.0 description: |- Event contract for the ComplyTime evidence lifecycle. diff --git a/docs/versioning.md b/docs/versioning.md index ba9543e..430da9b 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -30,8 +30,17 @@ subscribers or signals nothing. ### NATS subject — frozen routing address -The subject is where a subscriber listens (ADR-0020, `domain.action.entity` -hierarchy). It MUST NOT carry a version segment. Version information belongs in +The subject is where a subscriber listens (ADR-0020). In this codebase that +hierarchy is `domain.entity.action.{param}` — e.g. +`core.evidence.ingested.{subjectId}`, `core.evidence.sealed.{subjectId}`, +`core.evidence.quarantined.{subjectId}`. The subject's `domain` segment +(`core`) is a routing namespace only — it does not need to match the +CloudEvents `type`'s reverse-DNS namespace (`dev.complytime`). The two +namespaces serve different audiences: the subject namespace scopes NATS +routing/permissions, the `type` namespace scopes payload-contract identity. +Do not infer one from the other; dispatch on `type`, subscribe on subject. + +The subject MUST NOT carry a version segment. Version information belongs in the CloudEvents `type` (see below), per ADR-0021, which routes and filters on `type` and `source`. @@ -39,8 +48,10 @@ Keeping version out of the subject means subscribers bind once, with a wildcard, and never re-subscribe: ``` -core.evidence.ingested.* # every subjectId -core.evidence.ingested.> # every subjectId and any deeper segments +core.evidence.ingested.* # every subjectId, ingested outcome only +core.evidence.sealed.* # every subjectId, sealed outcome only +core.evidence.quarantined.* # every subjectId, quarantined outcome only +core.evidence.> # every outcome, every subjectId, any deeper segments ``` That single binding survives both a new `subjectId` and a new payload version, diff --git a/events/events.go b/events/events.go index 3776592..18754b3 100644 --- a/events/events.go +++ b/events/events.go @@ -4,7 +4,7 @@ // evidence lifecycle. package events -//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.1.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 +//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.2.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 import ( "errors" From 1bf933b8a93855f945e08d8aae85d54c74159c10 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 21:16:17 -0400 Subject: [PATCH 06/10] fix: enforce contentDigest/artifactType at construction time Add non-empty validation for contentDigest and artifactType in all three evidence event constructors, matching the required fields already declared in the generated JSON Schemas. Also add missing GoDoc on the CloudEvents type constants and correct the stale 0.1.0 info.version example in docs/versioning.md to 0.2.0. Assisted-by: Claude Code --- docs/versioning.md | 2 +- events/events.go | 24 ++++++++++++++++ events/events_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/docs/versioning.md b/docs/versioning.md index 430da9b..4d388fd 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -25,7 +25,7 @@ subscribers or signals nothing. |-------|-------|---------|----------|--------| | NATS subject | channel address | `core.evidence.ingested.{subjectId}` | Nothing — routing address only | Broker (routing) | | CloudEvents type | `type` | `dev.complytime.evidence.ingested` | The event's payload contract (breaking changes) | Consumer per-message dispatch | -| AsyncAPI contract | `info.version` | `0.1.0` | The whole published contract | Humans, codegen tooling | +| AsyncAPI contract | `info.version` | `0.2.0` | The whole published contract | Humans, codegen tooling | | CloudEvents envelope | `specversion` | `1.0` | The CNCF envelope format — not ours | Nobody (hands off) | ### NATS subject — frozen routing address diff --git a/events/events.go b/events/events.go index 18754b3..689644c 100644 --- a/events/events.go +++ b/events/events.go @@ -42,6 +42,8 @@ func validateSubjectID(subjectID string) error { return nil } +// TypeEvidenceIngested is the CloudEvents type for evidence accepted for +// processing, before validation. const TypeEvidenceIngested = "dev.complytime.evidence.ingested" // EvidenceIngestedData is the CloudEvents data payload for @@ -69,6 +71,12 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) if err := validateSubjectID(data.SubjectID); err != nil { return cloudevents.Event{}, err } + if data.ContentDigest == "" { + return cloudevents.Event{}, errors.New("contentDigest must not be empty") + } + if data.ArtifactType == "" { + return cloudevents.Event{}, errors.New("artifactType must not be empty") + } if data.StorageRef == "" { return cloudevents.Event{}, errors.New("storageRef must not be empty") } @@ -89,6 +97,8 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) return e, nil } +// TypeEvidenceSealed is the CloudEvents type for evidence a worker has +// validated and sealed into a unit of work. const TypeEvidenceSealed = "dev.complytime.evidence.sealed" // EvidenceSealedData is the CloudEvents data payload for evidence.sealed @@ -117,6 +127,12 @@ func NewEvidenceSealedEvent(source, subject string, data EvidenceSealedData) (cl if err := validateSubjectID(data.SubjectID); err != nil { return cloudevents.Event{}, err } + if data.ContentDigest == "" { + return cloudevents.Event{}, errors.New("contentDigest must not be empty") + } + if data.ArtifactType == "" { + return cloudevents.Event{}, errors.New("artifactType must not be empty") + } e := event.New(cloudevents.VersionV1) e.SetID(uuid.New().String()) @@ -131,6 +147,8 @@ func NewEvidenceSealedEvent(source, subject string, data EvidenceSealedData) (cl return e, nil } +// TypeEvidenceQuarantined is the CloudEvents type for evidence a worker +// failed to validate and quarantined. const TypeEvidenceQuarantined = "dev.complytime.evidence.quarantined" // EvidenceQuarantinedData is the CloudEvents data payload for @@ -160,6 +178,12 @@ func NewEvidenceQuarantinedEvent(source, subject string, data EvidenceQuarantine if err := validateSubjectID(data.SubjectID); err != nil { return cloudevents.Event{}, err } + if data.ContentDigest == "" { + return cloudevents.Event{}, errors.New("contentDigest must not be empty") + } + if data.ArtifactType == "" { + return cloudevents.Event{}, errors.New("artifactType must not be empty") + } if data.Reason == "" { return cloudevents.Event{}, errors.New("reason must not be empty") } diff --git a/events/events_test.go b/events/events_test.go index 1355e8f..2ca4324 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -194,6 +194,28 @@ func TestNewEvidenceIngestedEventEmptySubject(t *testing.T) { } } +func TestNewEvidenceIngestedEventEmptyContentDigest(t *testing.T) { + data := EvidenceIngestedData{ + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "locker://ref/123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for empty contentDigest") + } +} + +func TestNewEvidenceIngestedEventEmptyArtifactType(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + StorageRef: "locker://ref/123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for empty artifactType") + } +} + func TestNewEvidenceIngestedEventWireFormatRoundTrip(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", @@ -325,6 +347,26 @@ func TestNewEvidenceSealedEventInvalidSubjectID(t *testing.T) { } } +func TestNewEvidenceSealedEventEmptyContentDigest(t *testing.T) { + data := EvidenceSealedData{ + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty contentDigest") + } +} + +func TestNewEvidenceSealedEventEmptyArtifactType(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty artifactType") + } +} + func TestNewEvidenceQuarantinedEvent(t *testing.T) { data := EvidenceQuarantinedData{ ContentDigest: "sha256:abc123", @@ -363,6 +405,28 @@ func TestNewEvidenceQuarantinedEventEmptyReason(t *testing.T) { } } +func TestNewEvidenceQuarantinedEventEmptyContentDigest(t *testing.T) { + data := EvidenceQuarantinedData{ + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + Reason: "content digest mismatch", + } + if _, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty contentDigest") + } +} + +func TestNewEvidenceQuarantinedEventEmptyArtifactType(t *testing.T) { + data := EvidenceQuarantinedData{ + ContentDigest: "sha256:abc123", + SubjectID: "my-app-v1", + Reason: "content digest mismatch", + } + if _, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty artifactType") + } +} + func TestExamplePayloads_ConformToSchema(t *testing.T) { examplesDir := filepath.Join("..", "api", "events", "examples") files, err := filepath.Glob(filepath.Join(examplesDir, "*.json")) From 6cf4a139221eddd649b9e3c3694b6254379955ea Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 21:21:45 -0400 Subject: [PATCH 07/10] refactor: rename NATS subject domain segment from core to complyapi --- README.md | 2 +- api/events/asyncapi.yaml | 6 +++--- docs/versioning.md | 19 ++++++++++--------- events/events.go | 8 ++++---- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5618f21..f880c95 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ task check Example sentinel field: ```go - _ struct{} `asyncapi:"channel:core.widget.created.{ownerId},param:ownerId=The widget owner,stream:WIDGETS,type:dev.complytime.widget.created,send:Published when a widget is created,receive:Consume widget-created events,description:Widget creation pipeline"` + _ struct{} `asyncapi:"channel:complyapi.widget.created.{ownerId},param:ownerId=The widget owner,stream:WIDGETS,type:dev.complytime.widget.created,send:Published when a widget is created,receive:Consume widget-created events,description:Widget creation pipeline"` ``` 2. Add `asyncapi-field:"description:..."` tags on each struct field for diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index 02bc7ee..b22f51f 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -21,7 +21,7 @@ servers: protocol: nats channels: evidenceIngested: - address: core.evidence.ingested.{subjectId} + address: complyapi.evidence.ingested.{subjectId} description: Evidence ingestion pipeline for compliance artifacts parameters: subjectId: @@ -30,7 +30,7 @@ channels: EvidenceIngested: $ref: '#/components/messages/EvidenceIngested' evidenceQuarantined: - address: core.evidence.quarantined.{subjectId} + address: complyapi.evidence.quarantined.{subjectId} description: Evidence quarantine pipeline for compliance artifacts parameters: subjectId: @@ -39,7 +39,7 @@ channels: EvidenceQuarantined: $ref: '#/components/messages/EvidenceQuarantined' evidenceSealed: - address: core.evidence.sealed.{subjectId} + address: complyapi.evidence.sealed.{subjectId} description: Evidence sealing pipeline for compliance artifacts parameters: subjectId: diff --git a/docs/versioning.md b/docs/versioning.md index 4d388fd..e182330 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -23,7 +23,7 @@ subscribers or signals nothing. | Layer | Field | Example | Versions | Reacts | |-------|-------|---------|----------|--------| -| NATS subject | channel address | `core.evidence.ingested.{subjectId}` | Nothing — routing address only | Broker (routing) | +| NATS subject | channel address | `complyapi.evidence.ingested.{subjectId}` | Nothing — routing address only | Broker (routing) | | CloudEvents type | `type` | `dev.complytime.evidence.ingested` | The event's payload contract (breaking changes) | Consumer per-message dispatch | | AsyncAPI contract | `info.version` | `0.2.0` | The whole published contract | Humans, codegen tooling | | CloudEvents envelope | `specversion` | `1.0` | The CNCF envelope format — not ours | Nobody (hands off) | @@ -32,10 +32,11 @@ subscribers or signals nothing. The subject is where a subscriber listens (ADR-0020). In this codebase that hierarchy is `domain.entity.action.{param}` — e.g. -`core.evidence.ingested.{subjectId}`, `core.evidence.sealed.{subjectId}`, -`core.evidence.quarantined.{subjectId}`. The subject's `domain` segment -(`core`) is a routing namespace only — it does not need to match the -CloudEvents `type`'s reverse-DNS namespace (`dev.complytime`). The two +`complyapi.evidence.ingested.{subjectId}`, `complyapi.evidence.sealed.{subjectId}`, +`complyapi.evidence.quarantined.{subjectId}`. The subject's `domain` segment +(`complyapi`) names the library/service that owns this event contract — it +is a routing namespace only, and it does not need to match the CloudEvents +`type`'s reverse-DNS namespace (`dev.complytime`, the org's domain). The two namespaces serve different audiences: the subject namespace scopes NATS routing/permissions, the `type` namespace scopes payload-contract identity. Do not infer one from the other; dispatch on `type`, subscribe on subject. @@ -48,10 +49,10 @@ Keeping version out of the subject means subscribers bind once, with a wildcard, and never re-subscribe: ``` -core.evidence.ingested.* # every subjectId, ingested outcome only -core.evidence.sealed.* # every subjectId, sealed outcome only -core.evidence.quarantined.* # every subjectId, quarantined outcome only -core.evidence.> # every outcome, every subjectId, any deeper segments +complyapi.evidence.ingested.* # every subjectId, ingested outcome only +complyapi.evidence.sealed.* # every subjectId, sealed outcome only +complyapi.evidence.quarantined.* # every subjectId, quarantined outcome only +complyapi.evidence.> # every outcome, every subjectId, any deeper segments ``` That single binding survives both a new `subjectId` and a new payload version, diff --git a/events/events.go b/events/events.go index 689644c..cdcaa17 100644 --- a/events/events.go +++ b/events/events.go @@ -21,7 +21,7 @@ import ( // literal NATS subject token: alphanumerics, dash, and underscore. It // excludes ".", "*", and ">" — the NATS token separator and wildcards — so // a subjectId can never reshape or collide with a subscriber's wildcard -// binding (e.g. "core.evidence.ingested.*"). +// binding (e.g. "complyapi.evidence.ingested.*"). var subjectIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // storageRefPattern requires storageRef to carry a URI-style scheme prefix @@ -50,7 +50,7 @@ const TypeEvidenceIngested = "dev.complytime.evidence.ingested" // evidence.ingested events. type EvidenceIngestedData struct { //nolint:unused - _ struct{} `asyncapi:"channel:core.evidence.ingested.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.ingested,send:Published when evidence is accepted for processing; before sealing,receive:Consume evidence-ingested events,description:Evidence ingestion pipeline for compliance artifacts"` + _ struct{} `asyncapi:"channel:complyapi.evidence.ingested.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.ingested,send:Published when evidence is accepted for processing; before sealing,receive:Consume evidence-ingested events,description:Evidence ingestion pipeline for compliance artifacts"` ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` @@ -107,7 +107,7 @@ const TypeEvidenceSealed = "dev.complytime.evidence.sealed" // a unit of work. type EvidenceSealedData struct { //nolint:unused - _ struct{} `asyncapi:"channel:core.evidence.sealed.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.sealed,send:Published when a worker validates and seals evidence into a unit of work,receive:Consume evidence-sealed events,description:Evidence sealing pipeline for compliance artifacts"` + _ struct{} `asyncapi:"channel:complyapi.evidence.sealed.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.sealed,send:Published when a worker validates and seals evidence into a unit of work,receive:Consume evidence-sealed events,description:Evidence sealing pipeline for compliance artifacts"` ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` @@ -156,7 +156,7 @@ const TypeEvidenceQuarantined = "dev.complytime.evidence.quarantined" // an ingested artifact. type EvidenceQuarantinedData struct { //nolint:unused - _ struct{} `asyncapi:"channel:core.evidence.quarantined.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.quarantined,send:Published when a worker fails to validate evidence and quarantines it,receive:Consume evidence-quarantined events,description:Evidence quarantine pipeline for compliance artifacts"` + _ struct{} `asyncapi:"channel:complyapi.evidence.quarantined.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.quarantined,send:Published when a worker fails to validate evidence and quarantines it,receive:Consume evidence-quarantined events,description:Evidence quarantine pipeline for compliance artifacts"` ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` From 896a49214616a40bb7df788076126e142eeeac1a Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Thu, 20 Aug 2026 21:30:20 -0400 Subject: [PATCH 08/10] refactor: drop shardId from evidence data structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShardID was an optional field on all three evidence outcome payloads. It is not needed at this stage. Removing it now is safe: the field was optional (omitempty), nothing is released, and no consumers are pinned. Re-adding it later is an additive, non-breaking change per the versioning strategy — a minor info.version bump with the subject and CloudEvents type unchanged. - Remove ShardID from EvidenceIngestedData, EvidenceSealedData, and EvidenceQuarantinedData - Drop the two shardId JSON marshalling tests - Delete the now-redundant minimal and with-shard example payloads (with no optional fields, minimal is identical in shape to the standard ingested example) - Collapse the README example table to the three outcome payloads - Regenerate asyncapi.yaml and JSON schemas; bump info.version to 0.3.0 Assisted-by: Claude Code Signed-off-by: Jennifer Power --- README.md | 4 +- api/events/asyncapi.yaml | 11 +---- .../examples/evidence-ingested-minimal.json | 15 ------ .../evidence-ingested-with-shard.json | 16 ------- .../schemas/EvidenceIngestedData.schema.json | 4 -- .../EvidenceQuarantinedData.schema.json | 4 -- .../schemas/EvidenceSealedData.schema.json | 4 -- events/events.go | 23 ++++------ events/events_test.go | 46 ------------------- 9 files changed, 12 insertions(+), 115 deletions(-) delete mode 100644 api/events/examples/evidence-ingested-minimal.json delete mode 100644 api/events/examples/evidence-ingested-with-shard.json diff --git a/README.md b/README.md index f880c95..c280a75 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,7 @@ Example CloudEvents JSON payloads are in | File | Description | |------|-------------| -| [`evidence-ingested.json`](api/events/examples/evidence-ingested.json) | Common payload with required fields and `storageRef` | -| [`evidence-ingested-minimal.json`](api/events/examples/evidence-ingested-minimal.json) | Required data fields only (no optional fields) | -| [`evidence-ingested-with-shard.json`](api/events/examples/evidence-ingested-with-shard.json) | All fields including optional `shardId` | +| [`evidence-ingested.json`](api/events/examples/evidence-ingested.json) | Ingested outcome; all data fields including `storageRef` | | [`evidence-sealed.json`](api/events/examples/evidence-sealed.json) | Sealed outcome after successful validation | | [`evidence-quarantined.json`](api/events/examples/evidence-quarantined.json) | Quarantined outcome after failed validation, with `reason` | diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index b22f51f..def6279 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -2,7 +2,7 @@ asyncapi: 3.0.0 info: title: ComplyTime API Events - version: 0.2.0 + version: 0.3.0 description: |- Event contract for the ComplyTime evidence lifecycle. @@ -164,9 +164,6 @@ components: contentDigest: type: string description: SHA-256 digest of the evidence artifact - shardId: - type: string - description: Subject shard identifier (null when sharding is not configured) storageRef: type: string description: URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://) @@ -230,9 +227,6 @@ components: reason: type: string description: Why validation failed - shardId: - type: string - description: Subject shard identifier (null when sharding is not configured) subjectId: type: string description: Compliance subject identifier @@ -290,9 +284,6 @@ components: contentDigest: type: string description: SHA-256 digest of the evidence artifact - shardId: - type: string - description: Subject shard identifier (null when sharding is not configured) subjectId: type: string description: Compliance subject identifier diff --git a/api/events/examples/evidence-ingested-minimal.json b/api/events/examples/evidence-ingested-minimal.json deleted file mode 100644 index fad8154..0000000 --- a/api/events/examples/evidence-ingested-minimal.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "specversion": "1.0", - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "type": "dev.complytime.evidence.ingested", - "source": "complytime-gateway", - "subject": "my-app-v1", - "time": "2026-08-20T14:30:00Z", - "datacontenttype": "application/json", - "data": { - "contentDigest": "sha256:a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678", - "artifactType": "application/vnd.gemara.evaluation-log+json", - "storageRef": "locker://store/evidence/2026/08/20/minimal01", - "subjectId": "my-app-v1" - } -} diff --git a/api/events/examples/evidence-ingested-with-shard.json b/api/events/examples/evidence-ingested-with-shard.json deleted file mode 100644 index b0e5823..0000000 --- a/api/events/examples/evidence-ingested-with-shard.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "specversion": "1.0", - "id": "c9a1e2b3-d4f5-6789-0abc-def123456789", - "type": "dev.complytime.evidence.ingested", - "source": "complytime-gateway", - "subject": "my-app-v1", - "time": "2026-08-20T14:30:00Z", - "datacontenttype": "application/json", - "data": { - "contentDigest": "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - "artifactType": "application/vnd.gemara.evaluation-log+json", - "storageRef": "locker://store/evidence/2026/08/20/c9a1e2b3", - "subjectId": "my-app-v1", - "shardId": "shard-west-1" - } -} diff --git a/api/events/schemas/EvidenceIngestedData.schema.json b/api/events/schemas/EvidenceIngestedData.schema.json index 7ceb23b..f1dc79b 100644 --- a/api/events/schemas/EvidenceIngestedData.schema.json +++ b/api/events/schemas/EvidenceIngestedData.schema.json @@ -12,10 +12,6 @@ "description": "SHA-256 digest of the evidence artifact", "type": "string" }, - "shardId": { - "description": "Subject shard identifier (null when sharding is not configured)", - "type": "string" - }, "storageRef": { "description": "URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)", "type": "string" diff --git a/api/events/schemas/EvidenceQuarantinedData.schema.json b/api/events/schemas/EvidenceQuarantinedData.schema.json index c910b58..d208990 100644 --- a/api/events/schemas/EvidenceQuarantinedData.schema.json +++ b/api/events/schemas/EvidenceQuarantinedData.schema.json @@ -16,10 +16,6 @@ "description": "Why validation failed", "type": "string" }, - "shardId": { - "description": "Subject shard identifier (null when sharding is not configured)", - "type": "string" - }, "subjectId": { "description": "Compliance subject identifier", "type": "string" diff --git a/api/events/schemas/EvidenceSealedData.schema.json b/api/events/schemas/EvidenceSealedData.schema.json index fc93b5e..04d0269 100644 --- a/api/events/schemas/EvidenceSealedData.schema.json +++ b/api/events/schemas/EvidenceSealedData.schema.json @@ -12,10 +12,6 @@ "description": "SHA-256 digest of the evidence artifact", "type": "string" }, - "shardId": { - "description": "Subject shard identifier (null when sharding is not configured)", - "type": "string" - }, "subjectId": { "description": "Compliance subject identifier", "type": "string" diff --git a/events/events.go b/events/events.go index cdcaa17..5667fb0 100644 --- a/events/events.go +++ b/events/events.go @@ -4,7 +4,7 @@ // evidence lifecycle. package events -//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.2.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 +//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.3.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 import ( "errors" @@ -54,9 +54,8 @@ type EvidenceIngestedData struct { ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` - StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)"` - SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` - ShardID *string `json:"shardId,omitempty" asyncapi-field:"description:Subject shard identifier (null when sharding is not configured)"` + StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` } // NewEvidenceIngestedEvent constructs a CloudEvents v1.0 event with @@ -109,10 +108,9 @@ type EvidenceSealedData struct { //nolint:unused _ struct{} `asyncapi:"channel:complyapi.evidence.sealed.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.sealed,send:Published when a worker validates and seals evidence into a unit of work,receive:Consume evidence-sealed events,description:Evidence sealing pipeline for compliance artifacts"` - ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` - ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` - SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` - ShardID *string `json:"shardId,omitempty" asyncapi-field:"description:Subject shard identifier (null when sharding is not configured)"` + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` } // NewEvidenceSealedEvent constructs a CloudEvents v1.0 event with the @@ -158,11 +156,10 @@ type EvidenceQuarantinedData struct { //nolint:unused _ struct{} `asyncapi:"channel:complyapi.evidence.quarantined.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.quarantined,send:Published when a worker fails to validate evidence and quarantines it,receive:Consume evidence-quarantined events,description:Evidence quarantine pipeline for compliance artifacts"` - ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` - ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` - SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` - ShardID *string `json:"shardId,omitempty" asyncapi-field:"description:Subject shard identifier (null when sharding is not configured)"` - Reason string `json:"reason" asyncapi-field:"description:Why validation failed"` + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` + Reason string `json:"reason" asyncapi-field:"description:Why validation failed"` } // NewEvidenceQuarantinedEvent constructs a CloudEvents v1.0 event with the diff --git a/events/events_test.go b/events/events_test.go index 2ca4324..6263243 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -49,52 +49,6 @@ func TestEvidenceIngestedDataJSON(t *testing.T) { } } -func TestEvidenceIngestedDataJSONOmitsOptionalFields(t *testing.T) { - data := EvidenceIngestedData{ - ContentDigest: "sha256:abc123", - ArtifactType: "application/vnd.gemara.evaluation-log+json", - SubjectID: "my-app-v1", - } - - b, err := json.Marshal(data) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - raw := make(map[string]interface{}) - if err := json.Unmarshal(b, &raw); err != nil { - t.Fatalf("Unmarshal to map: %v", err) - } - - if _, ok := raw["shardId"]; ok { - t.Error("shardId should be omitted when nil") - } -} - -func TestEvidenceIngestedDataJSONIncludesShardID(t *testing.T) { - shard := "shard-1" - data := EvidenceIngestedData{ - ContentDigest: "sha256:abc123", - ArtifactType: "application/vnd.gemara.evaluation-log+json", - SubjectID: "my-app-v1", - ShardID: &shard, - } - - b, err := json.Marshal(data) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - raw := make(map[string]interface{}) - if err := json.Unmarshal(b, &raw); err != nil { - t.Fatalf("Unmarshal to map: %v", err) - } - - if raw["shardId"] != "shard-1" { - t.Errorf("shardId = %v, want %q", raw["shardId"], "shard-1") - } -} - func TestNewEvidenceIngestedEvent(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", From a06f79ef5932e77fc69423a6e0b00db6dec430ff Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Fri, 21 Aug 2026 17:10:53 -0400 Subject: [PATCH 09/10] feat: require storageRef on sealed events, validate example payloads Sealed evidence points at a durable WORM object, so EvidenceSealedData now carries storageRef and NewEvidenceSealedEvent enforces it is present with a URI scheme prefix, matching the ingested-event contract. Add TestExamplePayloads_ConformToSchema to validate the hand-maintained example payloads against the generated JSON schemas, preventing drift between docs and contract. Document the correlation model (join on contentDigest; observability via CloudEvents Distributed Tracing) in the README. Suppress four gosec false positives in cmd/asyncapi-gen with justified //nolint comments: paths come from developer-controlled go:generate flags, and G705 XSS does not apply to CLI stdout. Assisted-by: Claude Code Signed-off-by: Jennifer Power --- README.md | 15 ++ api/events/asyncapi.yaml | 6 +- api/events/examples/evidence-sealed.json | 1 + .../schemas/EvidenceSealedData.schema.json | 5 + cmd/asyncapi-gen/jsonschema.go | 2 +- cmd/asyncapi-gen/main.go | 4 +- cmd/asyncapi-gen/parser.go | 2 +- events/events.go | 29 +++- events/events_test.go | 153 ++++++++++-------- go.mod | 6 +- go.sum | 12 +- 11 files changed, 147 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index c280a75..ea8e635 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ func main() { data := events.EvidenceIngestedData{ ContentDigest: "sha256:abc123...", ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "s3://evidence-bucket/my-app-v1/evaluation-log.json", SubjectID: "my-app-v1", } @@ -50,6 +51,20 @@ func main() { | `dev.complytime.evidence.sealed` | `events.TypeEvidenceSealed` | Evidence validated and sealed into a unit of work | | `dev.complytime.evidence.quarantined` | `events.TypeEvidenceQuarantined` | Evidence failed validation and was quarantined | +### Correlation + +Events do not carry a dedicated correlation attribute. + +- **Join an artifact's lifecycle events** (`ingested` → + `sealed`|`quarantined`) on the shared `contentDigest`. Caveat: a + `quarantined` event whose `reason` is a content-digest mismatch is the one case + where the digest is itself in doubt. + +- **Trace across services** using the + [CloudEvents Distributed Tracing extension](https://github.com/cloudevents/spec/blob/main/cloudevents/extensions/distributed-tracing.md) + (`traceparent`/`tracestate`, W3C Trace Context). This is the observability + plane, set and propagated by the producer's tracing SDK, not by this library. + ### Payload Examples Example CloudEvents JSON payloads are in diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index def6279..573cc56 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -2,7 +2,7 @@ asyncapi: 3.0.0 info: title: ComplyTime API Events - version: 0.3.0 + version: 0.2.0 description: |- Event contract for the ComplyTime evidence lifecycle. @@ -276,6 +276,7 @@ components: required: - contentDigest - artifactType + - storageRef - subjectId properties: artifactType: @@ -284,6 +285,9 @@ components: contentDigest: type: string description: SHA-256 digest of the evidence artifact + storageRef: + type: string + description: URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcp://, locker://) subjectId: type: string description: Compliance subject identifier diff --git a/api/events/examples/evidence-sealed.json b/api/events/examples/evidence-sealed.json index cc7efa7..0d8e10f 100644 --- a/api/events/examples/evidence-sealed.json +++ b/api/events/examples/evidence-sealed.json @@ -9,6 +9,7 @@ "data": { "contentDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "artifactType": "application/vnd.gemara.evaluation-log+json", + "storageRef": "s3://evidence/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "subjectId": "my-app-v1" } } diff --git a/api/events/schemas/EvidenceSealedData.schema.json b/api/events/schemas/EvidenceSealedData.schema.json index 04d0269..ff25067 100644 --- a/api/events/schemas/EvidenceSealedData.schema.json +++ b/api/events/schemas/EvidenceSealedData.schema.json @@ -12,6 +12,10 @@ "description": "SHA-256 digest of the evidence artifact", "type": "string" }, + "storageRef": { + "description": "URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcp://, locker://)", + "type": "string" + }, "subjectId": { "description": "Compliance subject identifier", "type": "string" @@ -20,6 +24,7 @@ "required": [ "contentDigest", "artifactType", + "storageRef", "subjectId" ], "type": "object" diff --git a/cmd/asyncapi-gen/jsonschema.go b/cmd/asyncapi-gen/jsonschema.go index 8ac3712..35e5eba 100644 --- a/cmd/asyncapi-gen/jsonschema.go +++ b/cmd/asyncapi-gen/jsonschema.go @@ -80,7 +80,7 @@ func BuildEnvelopeJSONSchema(spec EventSpec) JSONSchema { // each event spec to the given directory. Creates the directory if it // does not exist. func WriteJSONSchemas(specs []EventSpec, dir string) error { - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // G703: dir is the developer-supplied -schemas-dir flag from the go:generate directive, not untrusted input; 0o755 is correct for output directories (SC-005) return fmt.Errorf("creating schemas directory: %w", err) } diff --git a/cmd/asyncapi-gen/main.go b/cmd/asyncapi-gen/main.go index 418eea9..13b2c66 100644 --- a/cmd/asyncapi-gen/main.go +++ b/cmd/asyncapi-gen/main.go @@ -83,9 +83,9 @@ func run(opts Options, stdout, stderr io.Writer) error { if err := WriteJSONSchemas(specs, opts.SchemasDir); err != nil { return fmt.Errorf("schema write error: %w", err) } - fmt.Fprintf(stdout, "asyncapi-gen: wrote JSON schemas to %s\n", opts.SchemasDir) + fmt.Fprintf(stdout, "asyncapi-gen: wrote JSON schemas to %s\n", opts.SchemasDir) //nolint:gosec // G705: stdout is a CLI status stream, not an HTML/browser sink; XSS does not apply } - fmt.Fprintf(stdout, "asyncapi-gen: wrote %s (%d event(s))\n", opts.Output, len(specs)) + fmt.Fprintf(stdout, "asyncapi-gen: wrote %s (%d event(s))\n", opts.Output, len(specs)) //nolint:gosec // G705: stdout is a CLI status stream, not an HTML/browser sink; XSS does not apply return nil } diff --git a/cmd/asyncapi-gen/parser.go b/cmd/asyncapi-gen/parser.go index 7053663..fa9117d 100644 --- a/cmd/asyncapi-gen/parser.go +++ b/cmd/asyncapi-gen/parser.go @@ -38,7 +38,7 @@ type FieldSpec struct { // per annotated struct. Returns an error if the file cannot be parsed or // a required tag key is missing. func ParseFile(path string) ([]EventSpec, error) { - src, err := os.ReadFile(path) + src, err := os.ReadFile(path) //nolint:gosec // G703: path is the developer-supplied -input flag from the go:generate directive, not untrusted input if err != nil { return nil, fmt.Errorf("reading file: %w", err) } diff --git a/events/events.go b/events/events.go index 5667fb0..3d1ca66 100644 --- a/events/events.go +++ b/events/events.go @@ -4,7 +4,7 @@ // evidence lifecycle. package events -//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.3.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 +//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.2.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 import ( "errors" @@ -42,6 +42,18 @@ func validateSubjectID(subjectID string) error { return nil } +// validateStorageRef returns an error if storageRef is empty or lacks a +// URI-style scheme prefix (e.g. "s3://", "gcp://", "locker://"). +func validateStorageRef(storageRef string) error { + if storageRef == "" { + return errors.New("storageRef must not be empty") + } + if !storageRefPattern.MatchString(storageRef) { + return fmt.Errorf("storageRef %q must have a URI scheme prefix (e.g. s3://, gcp://, locker://)", storageRef) + } + return nil +} + // TypeEvidenceIngested is the CloudEvents type for evidence accepted for // processing, before validation. const TypeEvidenceIngested = "dev.complytime.evidence.ingested" @@ -52,8 +64,8 @@ type EvidenceIngestedData struct { //nolint:unused _ struct{} `asyncapi:"channel:complyapi.evidence.ingested.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.ingested,send:Published when evidence is accepted for processing; before sealing,receive:Consume evidence-ingested events,description:Evidence ingestion pipeline for compliance artifacts"` - ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` - ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)"` SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` } @@ -76,11 +88,8 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) if data.ArtifactType == "" { return cloudevents.Event{}, errors.New("artifactType must not be empty") } - if data.StorageRef == "" { - return cloudevents.Event{}, errors.New("storageRef must not be empty") - } - if !storageRefPattern.MatchString(data.StorageRef) { - return cloudevents.Event{}, fmt.Errorf("storageRef %q must have a URI scheme prefix (e.g. s3://, gcp://, locker://)", data.StorageRef) + if err := validateStorageRef(data.StorageRef); err != nil { + return cloudevents.Event{}, err } e := event.New(cloudevents.VersionV1) @@ -110,6 +119,7 @@ type EvidenceSealedData struct { ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcp://, locker://)"` SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` } @@ -131,6 +141,9 @@ func NewEvidenceSealedEvent(source, subject string, data EvidenceSealedData) (cl if data.ArtifactType == "" { return cloudevents.Event{}, errors.New("artifactType must not be empty") } + if err := validateStorageRef(data.StorageRef); err != nil { + return cloudevents.Event{}, err + } e := event.New(cloudevents.VersionV1) e.SetID(uuid.New().String()) diff --git a/events/events_test.go b/events/events_test.go index 6263243..781327a 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -3,12 +3,14 @@ package events import ( + "bytes" "encoding/json" "os" "path/filepath" "testing" cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/santhosh-tekuri/jsonschema/v6" ) func TestTypeEvidenceIngestedConstant(t *testing.T) { @@ -266,6 +268,7 @@ func TestNewEvidenceSealedEvent(t *testing.T) { data := EvidenceSealedData{ ContentDigest: "sha256:abc123", ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "s3://evidence/abc123", SubjectID: "my-app-v1", } @@ -288,6 +291,32 @@ func TestNewEvidenceSealedEvent(t *testing.T) { if got.SubjectID != data.SubjectID { t.Errorf("data.SubjectID = %q, want %q", got.SubjectID, data.SubjectID) } + if got.StorageRef != data.StorageRef { + t.Errorf("data.StorageRef = %q, want %q", got.StorageRef, data.StorageRef) + } +} + +func TestNewEvidenceSealedEventEmptyStorageRef(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty storageRef") + } +} + +func TestNewEvidenceSealedEventInvalidStorageRef(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "evidence-bucket/abc123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for storageRef without a URI scheme prefix") + } } func TestNewEvidenceSealedEventInvalidSubjectID(t *testing.T) { @@ -382,7 +411,44 @@ func TestNewEvidenceQuarantinedEventEmptyArtifactType(t *testing.T) { } func TestExamplePayloads_ConformToSchema(t *testing.T) { + schemasDir := filepath.Join("..", "api", "events", "schemas") examplesDir := filepath.Join("..", "api", "events", "examples") + + // Register every generated schema with the compiler under a stable + // in-memory URL. The relative $id in each schema resolves against its + // registration URL, so the CloudEvent envelope's cross-file $ref to its + // *Data schema (e.g. "EvidenceIngestedData.schema.json") resolves too. + const base = "mem:///" + schemaFiles, err := filepath.Glob(filepath.Join(schemasDir, "*.schema.json")) + if err != nil { + t.Fatalf("glob schemas: %v", err) + } + if len(schemaFiles) == 0 { + t.Fatal("no schema files found in api/events/schemas/") + } + compiler := jsonschema.NewCompiler() + for _, path := range schemaFiles { + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read schema %s: %v", path, err) + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + t.Fatalf("parse schema %s: %v", path, err) + } + if err := compiler.AddResource(base+filepath.Base(path), doc); err != nil { + t.Fatalf("add schema %s: %v", path, err) + } + } + + // envelopeSchema maps a CloudEvents type to the envelope schema that + // validates a full example payload (envelope plus data) for that type. + envelopeSchema := map[string]string{ + TypeEvidenceIngested: "EvidenceIngestedCloudEvent.schema.json", + TypeEvidenceSealed: "EvidenceSealedCloudEvent.schema.json", + TypeEvidenceQuarantined: "EvidenceQuarantinedCloudEvent.schema.json", + } + files, err := filepath.Glob(filepath.Join(examplesDir, "*.json")) if err != nil { t.Fatalf("glob examples: %v", err) @@ -398,84 +464,29 @@ func TestExamplePayloads_ConformToSchema(t *testing.T) { t.Fatalf("read %s: %v", path, err) } + // The CloudEvents type selects which envelope schema applies. var envelope struct { - SpecVersion string `json:"specversion"` - ID string `json:"id"` - Type string `json:"type"` - Source string `json:"source"` - Subject string `json:"subject"` - Time string `json:"time"` - DataContentType string `json:"datacontenttype"` - Data json.RawMessage `json:"data"` + Type string `json:"type"` } if err := json.Unmarshal(b, &envelope); err != nil { - t.Fatalf("unmarshal: %v", err) + t.Fatalf("unmarshal type: %v", err) } - - if envelope.SpecVersion != "1.0" { - t.Errorf("specversion = %q, want %q", envelope.SpecVersion, "1.0") + schemaFile, ok := envelopeSchema[envelope.Type] + if !ok { + t.Fatalf("unhandled example type %q — add it to envelopeSchema", envelope.Type) } - if envelope.DataContentType != "application/json" { - t.Errorf("datacontenttype = %q, want %q", envelope.DataContentType, "application/json") - } - if envelope.ID == "" { - t.Error("id must not be empty") - } - if envelope.Source == "" { - t.Error("source must not be empty") - } - if envelope.Subject == "" { - t.Error("subject must not be empty") - } - if envelope.Time == "" { - t.Error("time must not be empty") + + sch, err := compiler.Compile(base + schemaFile) + if err != nil { + t.Fatalf("compile %s: %v", schemaFile, err) } - switch envelope.Type { - case TypeEvidenceIngested: - var data EvidenceIngestedData - if err := json.Unmarshal(envelope.Data, &data); err != nil { - t.Fatalf("unmarshal data: %v", err) - } - if data.ContentDigest == "" { - t.Error("data.contentDigest must not be empty") - } - if data.ArtifactType == "" { - t.Error("data.artifactType must not be empty") - } - if data.SubjectID == "" { - t.Error("data.subjectId must not be empty") - } - if data.StorageRef == "" { - t.Error("data.storageRef must not be empty") - } - case TypeEvidenceSealed: - var data EvidenceSealedData - if err := json.Unmarshal(envelope.Data, &data); err != nil { - t.Fatalf("unmarshal data: %v", err) - } - if data.ContentDigest == "" { - t.Error("data.contentDigest must not be empty") - } - if data.ArtifactType == "" { - t.Error("data.artifactType must not be empty") - } - if data.SubjectID == "" { - t.Error("data.subjectId must not be empty") - } - case TypeEvidenceQuarantined: - var data EvidenceQuarantinedData - if err := json.Unmarshal(envelope.Data, &data); err != nil { - t.Fatalf("unmarshal data: %v", err) - } - if data.SubjectID == "" { - t.Error("data.subjectId must not be empty") - } - if data.Reason == "" { - t.Error("data.reason must not be empty") - } - default: - t.Fatalf("unhandled example type %q — add a case to this switch", envelope.Type) + inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + t.Fatalf("parse example %s: %v", path, err) + } + if err := sch.Validate(inst); err != nil { + t.Errorf("%s does not conform to %s:\n%v", filepath.Base(path), schemaFile, err) } }) } diff --git a/go.mod b/go.mod index 6df5f29..4cd4b6d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/complytime/complyapi -go 1.26.5 +go 1.26.6 require ( github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/google/uuid v1.6.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,5 +14,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index b578de5..1739e68 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -21,6 +23,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= @@ -31,8 +35,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 9ba1b8e11cb6ab899961dd0176fb13959a74803e Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 24 Aug 2026 12:29:09 -0400 Subject: [PATCH 10/10] chore: apply suggestions from code review Signed-off-by: Jennifer Power Co-authored-by: Jennifer Power Co-authored-by: Hannah Braswell <135030802+hbraswelrh@users.noreply.github.com> --- api/events/asyncapi.yaml | 4 ++-- api/events/schemas/EvidenceIngestedData.schema.json | 2 +- api/events/schemas/EvidenceSealedData.schema.json | 2 +- events/events.go | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index 573cc56..609a0a6 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -166,7 +166,7 @@ components: description: SHA-256 digest of the evidence artifact storageRef: type: string - description: URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://) + description: URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcs://, locker://) subjectId: type: string description: Compliance subject identifier @@ -287,7 +287,7 @@ components: description: SHA-256 digest of the evidence artifact storageRef: type: string - description: URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcp://, locker://) + description: URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcs://, locker://) subjectId: type: string description: Compliance subject identifier diff --git a/api/events/schemas/EvidenceIngestedData.schema.json b/api/events/schemas/EvidenceIngestedData.schema.json index f1dc79b..c47e7a4 100644 --- a/api/events/schemas/EvidenceIngestedData.schema.json +++ b/api/events/schemas/EvidenceIngestedData.schema.json @@ -13,7 +13,7 @@ "type": "string" }, "storageRef": { - "description": "URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)", + "description": "URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcs://, locker://)", "type": "string" }, "subjectId": { diff --git a/api/events/schemas/EvidenceSealedData.schema.json b/api/events/schemas/EvidenceSealedData.schema.json index ff25067..b5cb582 100644 --- a/api/events/schemas/EvidenceSealedData.schema.json +++ b/api/events/schemas/EvidenceSealedData.schema.json @@ -13,7 +13,7 @@ "type": "string" }, "storageRef": { - "description": "URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcp://, locker://)", + "description": "URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcs://, locker://)", "type": "string" }, "subjectId": { diff --git a/events/events.go b/events/events.go index 3d1ca66..17ee886 100644 --- a/events/events.go +++ b/events/events.go @@ -25,7 +25,7 @@ import ( var subjectIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // storageRefPattern requires storageRef to carry a URI-style scheme prefix -// (e.g. "s3://", "gcp://", "locker://") per RFC 3986 scheme syntax. The set +// (e.g. "s3://", "gcs://", "locker://") per RFC 3986 scheme syntax. The set // of valid backends is not fixed, so this validates general shape rather // than an enumerated allowlist. var storageRefPattern = regexp.MustCompile(`^[a-z][a-z0-9+.-]*://`) @@ -43,13 +43,13 @@ func validateSubjectID(subjectID string) error { } // validateStorageRef returns an error if storageRef is empty or lacks a -// URI-style scheme prefix (e.g. "s3://", "gcp://", "locker://"). +// URI-style scheme prefix (e.g. "s3://", "gcs://", "locker://"). func validateStorageRef(storageRef string) error { if storageRef == "" { return errors.New("storageRef must not be empty") } if !storageRefPattern.MatchString(storageRef) { - return fmt.Errorf("storageRef %q must have a URI scheme prefix (e.g. s3://, gcp://, locker://)", storageRef) + return fmt.Errorf("storageRef %q must have a URI scheme prefix (e.g. s3://, gcs://, locker://)", storageRef) } return nil } @@ -66,7 +66,7 @@ type EvidenceIngestedData struct { ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` - StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcp://, locker://)"` + StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcs://, locker://)"` SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` } @@ -119,7 +119,7 @@ type EvidenceSealedData struct { ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` - StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcp://, locker://)"` + StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcs://, locker://)"` SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` }