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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .claude/skills/refresh-generated-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ overwrite.

## Scope

Wiring this into module-saas-starter's release, so a saas tag publishes a
matching SDK tag, is tracked in the solutions EPIC (obin-ai/lodestar#53, item
4). Until it lands, this walk is manual — which is a gap in the tooling, not a
step to get good at performing.
Release generation belongs to module-saas-starter and this SDK: a saas tag
must publish a matching SDK contract. Until release automation covers both,
this walk is manual — a gap in the owning tools, not a consumer responsibility.
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ gofmt -l . | grep -v '^gen/' # expect no output

`go test ./...` is load-bearing beyond the unit tests: `internal/apiboundary`
type-checks the entire public surface against the boundary rule below, and
`internal/agentcontext` checks these context files. There is no lint config —
`internal/agentcontext` checks these context files. Keep `golang.org/x/tools`
compatible with the active Go toolchain: its importer must understand Go's
export data for the boundary gate to run. There is no lint config —
`golangci-lint` being installed on your machine does not make it a gate here.

The same workflow's `gen-drift` job regenerates `gen/` from the ref in
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased — module audit emission

- Added `moduleauthority.Client.EmitAuditEvent`, preserving the complete event,
caller-selected idempotency key and host error codes through the existing
authenticated authority transport. Uncertain effects are not retried.
- Kept main's complete generated contract and existing artifact, installation
and delegation APIs. Receipt lookup and runtime-boundary attestation remain
deferred until their host contract is integrated. Module version stays 0.1.0.
- Updated the API boundary importer for Go 1.27.2 export data.

## Unreleased — current installation identity

- Added `moduleauthority.Client.GetCurrentInstallation` with independent module
Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,40 @@ The SDK surfaces are:
op, err := authority.MintModuleOperationContext(ctx, installedBindingID)
// op.Token goes in x-codefly-work-context on the call to op.Audience.
```
`EmitAuditEvent(ctx, AuditEvent)` sends a registered event through the same
module-authenticated authority seam. The host checks the module's grant,
tenant, actor, solution and registered field schema. The event's actor may be
the module's own principal or a `system:<process>` label; a subject actor must
belong to the tenant. An empty tenant requires the host's cross-tenant grant.

```go
// Persist the complete event and key in the producer's outbox before sending.
event := moduleauthority.AuditEvent{
Tenant: tenantID, EventType: "example.changed", Actor: "system:worker",
Solution: "example", EntryID: entryID, IdempotencyKey: outboxEventID,
Fields: map[string]any{"count": float64(3)},
}
if err := authority.EmitAuditEvent(ctx, event); err != nil {
return err // Keep the outbox entry and its original key for recovery.
}
// The host acknowledged the event; the producer may now settle its outbox row.
```

The host deduplicates by `(tenant, event_type, idempotency_key)`. Persist and
reuse the **complete original event and key** across retries and restarts;
never reuse a key for another intent. The contract in `SOURCE.txt` acknowledges
a duplicate key without comparing its payload. An empty key disables
deduplication, and some registered types require a key. Field values use the
protobuf Struct representation, so numbers are represented as doubles.

A nil error acknowledges the host's durable audit write or an existing key,
not completion of outbound webhook delivery. After a timeout or transport
error the write may already have committed. The SDK does not own an outbox,
store receipts, or automatically retry an uncertain effect; only rejected
module authentication is refreshed and retried once. Host error codes remain
available through `connect.CodeOf(err)`. Receipt lookup and runtime-boundary
attestation await an integrated host contract and are not exposed here.

- **`settings/`** — the schema-agnostic typed-settings library every module and
product depends on instead of vendoring a copy. It has two parts:

Expand Down Expand Up @@ -202,8 +236,9 @@ the correct module path. **Never hand-edit `gen/` or sed the module path** — t
protobuf file descriptors embed length-prefixed package strings and a text
rewrite corrupts them (panics at `init()`). Always regenerate.

> Wiring this regen into module-saas-starter's release (so a saas tag publishes a
> matching SDK tag) is tracked in the solutions EPIC (obin-ai/lodestar#53, item 4).
Regeneration and matching SDK releases are owned by `module-saas-starter` and
this repository. Until release automation covers both, follow the
`refresh-generated-api` procedure in `.claude/skills/`.

## Consuming

Expand Down
12 changes: 6 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ require (
connectrpc.com/connect v1.21.0
github.com/codefly-dev/core v0.3.32
github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260927024316-b3fb40d43099
golang.org/x/tools v0.49.0
golang.org/x/tools v0.52.0
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.12
)

require (
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/mod v0.42.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
)
24 changes: 12 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
Expand Down
56 changes: 56 additions & 0 deletions moduleauthority/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package moduleauthority

import (
"context"
"errors"
"fmt"

v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1"
"github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1/accountsv1connect"
"google.golang.org/protobuf/types/known/structpb"
)

var ErrInvalidAuditEvent = errors.New("module authority: incomplete or invalid audit event")

// AuditEvent is the complete intent presented to the host audit spine. Tenant
// may be empty only for an authorized system event. Actor and solution scope are
// checked by the host; naming them is not itself proof of authority.
type AuditEvent struct {
Tenant, EventType, Actor, Solution, EntryID string
Fields map[string]any
// A durable relay must persist this key and complete event before sending,
// and reuse them after a restart or uncertain acknowledgement. The host
// deduplicates by (tenant, event type, key), not by comparing intent. Never
// reuse a key for a different event. Empty disables deduplication.
IdempotencyKey string
}

func auditRequest(event AuditEvent) (*v1.ModuleEmitAuditEventRequest, error) {
if event.EventType == "" || event.Actor == "" || event.Solution == "" {
return nil, ErrInvalidAuditEvent
}
fields, err := structpb.NewStruct(event.Fields)
if err != nil {
return nil, fmt.Errorf("%w: unsupported field value", ErrInvalidAuditEvent)
}
return &v1.ModuleEmitAuditEventRequest{Tenant: event.Tenant, EventType: event.EventType,
Actor: event.Actor, Solution: event.Solution, EntryId: event.EntryID,
Fields: fields, IdempotencyKey: event.IdempotencyKey}, nil
}

// EmitAuditEvent asks the host to commit a registered event as this module.
// A nil error acknowledges the host's durable write or a previously used key;
// it does not mean that downstream webhook delivery has completed. Host error
// codes are preserved. Only rejected authentication is refreshed once; other
// errors leave the outcome uncertain and are never automatically retried.
func (c *Client) EmitAuditEvent(ctx context.Context, event AuditEvent) error {
request, err := auditRequest(event)
if err != nil {
return err
}
_, err = callAsModule(ctx, c, accountsv1connect.ModuleCapabilitiesServiceClient.EmitAuditEvent, request)
if err != nil {
return fmt.Errorf("module authority: emit audit event: %w", err)
}
return nil
}
159 changes: 159 additions & 0 deletions moduleauthority/audit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package moduleauthority

import (
"errors"
"fmt"
"reflect"
"testing"

"connectrpc.com/connect"
)

func TestAuditEmissionUsesModuleAuthority(t *testing.T) {
h := newHost(t)
client := h.client(t, moduleCredentials)
event := AuditEvent{Tenant: testTenant, EventType: "example.changed", Actor: testOwner,
Solution: "example", EntryID: "entry", Fields: map[string]any{"count": float64(3)}, IdempotencyKey: "effect-1"}
if err := client.EmitAuditEvent(t.Context(), event); err != nil {
t.Fatal(err)
}
calls := h.authority.snapshot()
if len(calls) != 1 {
t.Fatalf("calls=%d", len(calls))
}
call := calls[0]
if call.method != "/saas.accounts.v1.ModuleCapabilitiesService/EmitAuditEvent" {
t.Fatal(call.method)
}
if len(call.internalToken) != 1 || call.internalToken[0] != testInternalToken || len(call.workContext) != 1 || call.workContext[0] != "module.token1" || len(call.authorization) != 0 {
t.Fatal("wrong authority carriers")
}
e := call.audit
if e.GetTenant() != event.Tenant || e.GetActor() != event.Actor || e.GetEventType() != event.EventType || e.GetSolution() != event.Solution || e.GetEntryId() != event.EntryID || e.GetIdempotencyKey() != event.IdempotencyKey || !reflect.DeepEqual(e.GetFields().AsMap(), event.Fields) {
t.Fatal("audit intent changed in transport")
}
for _, attempt := range []struct {
key string
code connect.Code
}{{"actor-unresolved", connect.CodeFailedPrecondition}, {"unavailable", connect.CodeUnavailable}, {"deadline", connect.CodeDeadlineExceeded}, {"internal", connect.CodeInternal}} {
event.IdempotencyKey = attempt.key
before := len(h.authority.snapshot())
if err := client.EmitAuditEvent(t.Context(), event); connect.CodeOf(err) != attempt.code {
t.Fatal(err)
}
if len(h.authority.snapshot()) != before+1 {
t.Fatal("effect error was retried")
}
}
h.assertNoStrayPaths(t)
}

func TestAuditInvalidIntentDoesNotReachHost(t *testing.T) {
h := newHost(t)
client := h.client(t, moduleCredentials)
for _, event := range []AuditEvent{
{}, {EventType: "example.changed", Solution: "example"},
{EventType: "example.changed", Actor: testOwner, Solution: "example", Fields: map[string]any{"bad": make(chan int)}},
} {
if err := client.EmitAuditEvent(t.Context(), event); !errors.Is(err, ErrInvalidAuditEvent) {
t.Fatal(err)
}
}
if len(h.authority.snapshot()) != 0 {
t.Fatal("invalid intent reached host")
}
}

// Recovery state belongs to the producer. Recreating an SDK client must not
// replace its persisted key, invent a new event, or replay an uncertain emit.
func TestAuditEmissionPreservesIntentAcrossClientRestart(t *testing.T) {
h := newHost(t)
event := AuditEvent{Tenant: testTenant, EventType: "example.changed", Actor: testOwner,
Solution: "example", EntryID: "entry", Fields: map[string]any{"count": float64(3)}, IdempotencyKey: "unavailable"}
for range 2 {
client := h.client(t, moduleCredentials)
if err := client.EmitAuditEvent(t.Context(), event); connect.CodeOf(err) != connect.CodeUnavailable {
t.Fatalf("uncertain emit: %v", err)
}
if err := client.Close(); err != nil {
t.Fatal(err)
}
}
calls := h.authority.snapshot()
if len(calls) != 2 {
t.Fatalf("got %d attempts for two explicit emits; uncertain effects must not be retried", len(calls))
}
for _, call := range calls {
if call.method != "/saas.accounts.v1.ModuleCapabilitiesService/EmitAuditEvent" ||
call.audit.GetTenant() != event.Tenant || call.audit.GetEventType() != event.EventType ||
call.audit.GetActor() != event.Actor || call.audit.GetSolution() != event.Solution ||
call.audit.GetEntryId() != event.EntryID || call.audit.GetIdempotencyKey() != event.IdempotencyKey ||
!reflect.DeepEqual(call.audit.GetFields().AsMap(), event.Fields) {
t.Fatal("restarting the client changed the caller's audit intent")
}
}
h.assertNoStrayPaths(t)
}

func TestAuditEmissionRefreshesOnlyRejectedModuleAuthentication(t *testing.T) {
for _, scenario := range []struct {
name string
rejectAll bool
badToken bool
wantCode connect.Code
wantCalls int
}{
{name: "expired capability", wantCalls: 2},
{name: "revoked module", rejectAll: true, wantCode: connect.CodeUnauthenticated, wantCalls: 2},
{name: "invalid perimeter", badToken: true, wantCode: connect.CodePermissionDenied, wantCalls: 1},
} {
t.Run(scenario.name, func(t *testing.T) {
h := newHost(t)
client := h.client(t, moduleCredentials)
if _, err := client.ModuleWorkContext(t.Context()); err != nil {
t.Fatal(err)
}
h.gateway.revoke("module.token1")
h.gateway.mu.Lock()
h.gateway.rejectAll = scenario.rejectAll
h.gateway.mu.Unlock()
if scenario.badToken {
client.authority.token = "invalid-perimeter-token"
}
event := AuditEvent{Tenant: testTenant, EventType: "example.changed", Actor: testOwner,
Solution: "example", IdempotencyKey: "stable-key"}
err := client.EmitAuditEvent(t.Context(), event)
if scenario.wantCode == 0 && err != nil || scenario.wantCode != 0 && connect.CodeOf(err) != scenario.wantCode {
t.Fatalf("emit error = %v, want code %v", err, scenario.wantCode)
}
calls := h.authority.snapshot()
if len(calls) != scenario.wantCalls {
t.Fatalf("got %d calls, want %d", len(calls), scenario.wantCalls)
}
for i, call := range calls {
if len(call.workContext) != 1 || call.workContext[0] != fmt.Sprintf("module.token%d", i+1) || call.audit.GetIdempotencyKey() != event.IdempotencyKey {
t.Fatal("authentication refresh changed the intent or reused the rejected credential")
}
}
h.assertNoStrayPaths(t)
})
}
}

func TestAuditEmissionWithoutAuthorityDoesNotMint(t *testing.T) {
h := newHost(t)
seams := h.seams()
seams.Authority = Authority{}
client, err := New(seams, moduleCredentials)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = client.Close() })
err = client.EmitAuditEvent(t.Context(), AuditEvent{EventType: "example.changed", Actor: testOwner, Solution: "example"})
if !errors.Is(err, ErrNoAuthority) {
t.Fatalf("got %v, want ErrNoAuthority", err)
}
if calls, _, _, _ := h.gateway.snapshot(); len(calls) != 0 || len(h.authority.snapshot()) != 0 {
t.Fatal("missing authority seam still contacted the host")
}
}
Loading
Loading