From de58ad403c8ef6f13e71e886aca21a5140e15a9e Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:57:51 +0200 Subject: [PATCH 1/5] fix: deprecate AuditTrailsOptions.Offset, which is never sent Audit trails are paginated by cursor: the endpoint declares event_name, after, before, limit and cursor, and no offset. ListAuditTrails stopped sending Offset when it moved to the cursor walk, but the field still read as an option that does something. It now carries a Deprecated notice that says it has no effect and points at Limit. Nothing is removed, so every caller still compiles. The comment on Limit said audit trails are never walked to completion. They are whenever no Limit is set: a nil or zero ceiling walks the whole trail, like every other list. TestListAuditTrailsSendsOnlyWhatTheEndpointDeclares pins both: 300 entries come back over two pages without a Limit, and the query carries the three filters and the paging pair, with no offset. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- inputs.go | 16 +++++++++------- pagination_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/inputs.go b/inputs.go index b727531..97c37de 100644 --- a/inputs.go +++ b/inputs.go @@ -42,19 +42,21 @@ type AuditTrailsOptions struct { After *time.Time Before *time.Time - // Limit caps how many entries are returned. Unlike every other list in - // this package, audit trails are not walked to completion: the collection - // grows without bound, so "all of them" is not a useful default and a - // caller's ceiling is honoured as one. + // Limit caps how many entries are returned. Audit trails are the one list + // in this package that takes a ceiling: the collection grows without + // bound, so a caller's ceiling is honoured as one. Nil or zero sets none, + // and the whole trail is then walked, like every other list. Limit *int32 - // Offset is dead. Audit trails moved from offset/limit to cursor - // pagination, so the Core API no longer declares this parameter and - // ignores it -- an offset that used to skip entries now returns the newest + // Offset is dead and never sent. Audit trails moved from offset/limit to + // cursor pagination, so the Core API no longer declares this parameter and + // would ignore it -- an offset that used to skip entries now returns the newest // ones. Left in place so callers still compile. Use Limit instead: it // walks as many pages as it takes to reach the count asked for, which // covers what offset was reached for in practice, and only resuming from // where a previous call stopped is no longer expressible. + // + // Deprecated: Offset has no effect. Use Limit. Offset *int32 } diff --git a/pagination_test.go b/pagination_test.go index 52db0d1..24bc52d 100644 --- a/pagination_test.go +++ b/pagination_test.go @@ -8,8 +8,11 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/http" + "slices" "testing" + "time" ) // pagedRows answers with the envelope Core actually sends, cutting rows into pages of @@ -179,6 +182,47 @@ func TestListAuditTrailsHonoursLimit(t *testing.T) { } } +// TestListAuditTrailsSendsOnlyWhatTheEndpointDeclares pins what AuditTrailsOptions +// promises beyond its Limit. Without one the trail is walked to its end, like +// any other list. And Offset never reaches the wire: the endpoint paginates by +// cursor and declares no such parameter, so the query carries the three filters +// and the paging pair the walk sets, under the endpoint's own names. +func TestListAuditTrailsSendsOnlyWhatTheEndpointDeclares(t *testing.T) { + t.Parallel() + + client, sent := givenClient(t, pagedRows(flagRows(300))) + + since := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) + until := since.AddDate(0, 1, 0) + offset := int32(50) + entries, err := client.Instances.ListAuditTrails(t.Context(), "instance-slug", &AuditTrailsOptions{ + EventName: "INSTANCE_CREATED", + After: &since, + Before: &until, + Offset: &offset, + }) + if err != nil { + t.Fatalf("ListAuditTrails() error = %v", err) + } + + if len(entries) != 300 { + t.Fatalf("len(entries) = %d, want 300", len(entries)) + } + + want := [][]string{ + {"after", "before", "event_name", "limit"}, + {"after", "before", "cursor", "event_name", "limit"}, + } + if sent.count() != len(want) { + t.Fatalf("requests = %d, want %d", sent.count(), len(want)) + } + for i, keys := range want { + if got := slices.Sorted(maps.Keys(sent.at(t, i).query)); !slices.Equal(got, keys) { + t.Errorf("request %d query keys = %v, want %v", i, got, keys) + } + } +} + // TestListPathsAndSubresourcePaths pins the paths the hand-built requests // produce. Nothing but this test checks them, and a wrong path is a 404 at // runtime rather than a compile error. From 1ca6f59eda699a7e94d28a43360c8ec23c2d5dc6 Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:58:01 +0200 Subject: [PATCH 2/5] docs: say what Create and Update do with the fields they never send Two inputs accept a value that one of their two calls drops, and their comments either did not say so or gave a reason the API contradicts. - LicenseInput.Version on create. The comment said the create body declares no such field. It does, read-only: the server assigns the number and refuses a create that carries one, 422 CreateLicense.VersionNotSettable. Not sending it stays right, since an input filled once for both calls would otherwise fail every create. The comment now says the value is dropped, and what the API would answer. - ComponentInput.PreviousComponentID on update. The field had no comment, and the payload's said the update body does not publish it. The body does declare it, and the API refuses any value there, the stored one included, 422 UpdateComponent.PreviousComponentNotSettable. So Update still does not send it: a component read and written back would be refused. The type now says the field is create-only. No behaviour changes: TestRequestBodiesMatchTheirSpecBodies already pins both payloads without these keys. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- inputs.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/inputs.go b/inputs.go index 97c37de..fd2070e 100644 --- a/inputs.go +++ b/inputs.go @@ -61,6 +61,11 @@ type AuditTrailsOptions struct { } // ComponentInput is the input for creating or updating a component. +// +// PreviousComponentID names the component this one is the next version of, and is +// create-only. Update never sends it: the link is set once, at creation, and the API +// refuses an update that carries one, whatever its value, with a 422, +// UpdateComponent.PreviousComponentNotSettable. type ComponentInput struct { Name string Version string @@ -235,7 +240,7 @@ type InstanceInput struct { // bodies are not either: // // - Version is read-only. A family's first version is 1 and the server assigns the -// next number from there, so the create body declares no such field at all. +// next number from there, so Create never sends it and a value set there is lost. // Update sends it only as an echo of what was read, since a version's slug is // built from its number. // - Slug is chosen on create and never changed. Update sends it for the API to check @@ -256,7 +261,9 @@ type LicenseInput struct { Type LicenseType // Version is the license's version within its family, assigned by the server - // and never changed. Create ignores it. Update sends it, and the API accepts it + // and never changed. It cannot be chosen. Create never sends it, so a value set + // there is dropped without an error: the API would refuse it with a 422, + // CreateLicense.VersionNotSettable. Update sends it, and the API accepts it // empty or equal to the stored number -- so a License read back can be written // back as it is -- and refuses any other value with a 422, // UpdateLicense.VersionNotSettable. @@ -455,7 +462,7 @@ func (in ComponentInput) createPayload() componentCreatePayload { } // componentUpdatePayload drops PreviousComponentID: the predecessor link is established -// when a component is created and UpdateComponentBody does not publish it. +// when a component is created, and the update refuses the field rather than ignore it. type componentUpdatePayload struct { Name string `json:"name"` Version string `json:"version"` From 10c913915256980d79c44e42d624cca630d1e6d6 Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:58:39 +0200 Subject: [PATCH 3/5] docs: make the README say what the client and the API do Statements the code contradicts, each checked against the client and the API it calls: - AuditTrailsOptions.Offset was listed as still sent. It is not: the API paginates by cursor only. InstancesListOptions.IncludeDeleted is the one that is sent and ignored. - ListAuditTrails was "the one exception" to lists returning everything. It is the one list that takes a ceiling; without a Limit it walks the whole trail. - Error codes are .: GetInstance.NotFound and GetLicense.NotFound, not Instance.NotFound and License.NotFound. The comment on Error.Code carried the same example. - Audit event names are upper snake case: the filter example used "instance.updated", which matches nothing. - Port 3000 is the console's. The Core API is https://app.kaiten.sh/api on Kaiten Cloud and http://localhost:6060/api on a local Community Edition stack. The Platform API does default to 3001, and the local stack publishes it on 6001. - The API's OpenAPI document is at /api/openapi.json. /api/docs is the reference page rendered from it. - A usage report is refused past what the license grants, allowance included, not when it reaches the grant's value. The Go blocks still compile against the module. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +++++++++++++++++++++------------ helpers.go | 2 +- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e6ca377..fea603f 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,11 @@ func main() { } ``` -The base URL includes the API's path prefix. Every Kaiten deployment serves its own -OpenAPI document at `/api/docs`; the [Kaiten documentation](https://docs.kaiten.sh) -describes the resources and fields. +The base URL includes the API's path prefix: `https://app.kaiten.sh/api` on Kaiten +Cloud, `http://localhost:6060/api` on a local Community Edition stack. The API serves +its own OpenAPI document at `/api/openapi.json` and a reference rendered from it at +`/api/docs`; the [Kaiten documentation](https://docs.kaiten.sh) describes the resources +and fields. ## Two APIs, two clients @@ -76,10 +78,14 @@ Kaiten exposes two contracts on two listeners, and this package mirrors that spl | | `sdk.NewClient` | `sdk.NewPlatformClient` | | -------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Contract | Core API | Platform API | -| Default port | 3000 | 3001 | +| Local stack | `http://localhost:6060/api` | `http://localhost:6001/api` | | Credential | organization-scoped token (`ksh_...`) | platform credential (`ksm_...`) | | Resources | customers, instances, licenses, entitlements, feature flags, components | organizations, users, organization tokens, connector registry | +The Platform API is an internal listener, for the operator of a self-hosted Kaiten: it +is on port 3001 unless configured otherwise, and the local Community Edition stack +publishes it on 6001. + Each listener refuses the other's credential, so a process that needs both builds both, against different base URLs. `Option` values are shared between the two constructors. @@ -219,7 +225,7 @@ stay. // Report a delta against a metered entitlement. err := client.Instances.ReportUsage(ctx, "acme-production", "api-calls", 1) if errors.Is(err, sdk.ErrThresholdExceeded) { - // The instance has reached its limit for this entitlement. + // Refused: usage would pass what the license grants, allowance included. } // Or set the absolute value and get the resulting metric back. @@ -318,21 +324,24 @@ connector, err := platform.Connectors.Register(ctx, sdk.RegisterConnectorInput{ Every list method returns the whole collection: the SDK walks the API's cursor pages itself, and a caller never sees a cursor. -The one exception is `Instances.ListAuditTrails`, whose collection grows without -bound. `AuditTrailsOptions.Limit` caps how many entries are fetched, and +`Instances.ListAuditTrails` is the one list that takes a ceiling, because its +collection grows without bound. `AuditTrailsOptions.Limit` caps how many entries are +fetched -- without one the whole trail is walked, like any other list -- and `EventName`, `After` and `Before` filter them. ```go limit := int32(100) trails, err := client.Instances.ListAuditTrails(ctx, "acme-production", &sdk.AuditTrailsOptions{ - EventName: "instance.updated", + EventName: "FEATURE_FLAG_EVALUATED", After: &since, Limit: &limit, }) ``` -`InstancesListOptions.IncludeDeleted` and `AuditTrailsOptions.Offset` are still sent -but ignored by current Kaiten versions; their doc comments say why they remain. +Two options remain only so that callers still compile, and their doc comments say +why: `InstancesListOptions.IncludeDeleted` is still sent, and ignored by current Kaiten +versions; `AuditTrailsOptions.Offset` is deprecated and never sent, since the API +paginates by cursor only. ## Error handling @@ -344,7 +353,7 @@ if err != nil { var apiErr *sdk.Error if errors.As(err, &apiErr) { switch { - case apiErr.Code == "Instance.NotFound": + case apiErr.Code == "GetInstance.NotFound": // Branch on Code: it is stable and distinguishes, for example, // the two different 404s a family endpoint can answer. case apiErr.StatusCode == http.StatusForbidden: @@ -363,7 +372,7 @@ if err != nil { | Field | Contents | | ------------ | --------------------------------------------------------------------- | | `StatusCode` | HTTP status code. | -| `Code` | Stable, machine-readable code such as `License.NotFound`, when given. | +| `Code` | Stable, machine-readable code such as `GetLicense.NotFound`, if any. | | `Problem` | The decoded problem-details body (`title`, `detail`, `errors`, ...). | | `ErrorID` | Correlation id for the server-side log entry, when given. | | `Body` | The raw response body. | diff --git a/helpers.go b/helpers.go index 9217079..6cd437e 100644 --- a/helpers.go +++ b/helpers.go @@ -30,7 +30,7 @@ type Error struct { Problem *ErrorModel Body []byte - // Code is the stable, machine-readable error code (e.g. "License.NotFound"), + // Code is the stable, machine-readable error code (e.g. "GetLicense.NotFound"), // when the API supplied one. Branch on this rather than on Problem.Title or the // status code alone: a 409 from an entitlement limit and a 409 from a slug // collision are the same status but not the same thing, and the license family From 365d4d0b6d0886d63bb0bb212570dc37be2e317a Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:58:49 +0200 Subject: [PATCH 4/5] docs: recast the README examples on Kaiten Sushi Shop The examples named an Acme organization and entities that exist nowhere: acme-production, team-v1, seats, api-calls. They now use Kaiten Sushi Shop, the dataset the Community Edition's demo profile seeds, so a reader who seeded it finds every name in their console: - customer Sakura Tokyo and its instance sakura-tokyo-prod, created with the slug and the environment metadata the dataset gives it; - licenses premium-v3, the draft a Publish needs, and premium-v2, the version that instance runs on and whose monthly-orders grant is the 1000 with a 20% allowance the example already described; - entitlements delivery-drivers and monthly-orders. Only names and values change. The calls are the same, and every Go block still compiles against the module. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fea603f..7d62979 100644 --- a/README.md +++ b/README.md @@ -128,19 +128,21 @@ operations below. ```go customer, err := client.Customers.Create(ctx, sdk.CustomerInput{ - Name: "Acme Corp", + Name: "Sakura Tokyo", }) if err != nil { return err } +slug := "sakura-tokyo-prod" instance, err := client.Instances.Create(ctx, sdk.InstanceInput{ - Name: "acme-production", + Name: "Sakura Tokyo Production", + Slug: &slug, CustomerID: *customer.Id, LicenseID: licenseID, StartLicenseDate: time.Now(), EndLicenseDate: time.Now().AddDate(1, 0, 0), - Metadata: map[string]any{"region": "eu-west-1"}, + Metadata: map[string]any{"environment": "production"}, }) if err != nil { return err @@ -164,16 +166,16 @@ A license is one version of a family; `LicenseFamilies` reads the family with it current version, `Licenses` writes individual versions. ```go -// Grant 25 seats to the license, then make it available to instances. -seats, err := sdk.NumberLicenseValue(25) +// Grant 10 delivery drivers to the draft license, then make it available to instances. +drivers, err := sdk.NumberLicenseValue(10) if err != nil { return err } -if err := client.Licenses.AssociateEntitlement(ctx, "team-v1", "seats", seats); err != nil { +if err := client.Licenses.AssociateEntitlement(ctx, "premium-v3", "delivery-drivers", drivers); err != nil { return err } -license, err := client.Licenses.Publish(ctx, "team-v1") +license, err := client.Licenses.Publish(ctx, "premium-v3") if err != nil { return err } @@ -194,7 +196,7 @@ usage past its value: slug, number, sum := "monthly-orders", sdk.EntitlementType("NUMBER"), sdk.EntitlementAggregationMethod("SUM") month, calendar, warning := sdk.EntitlementResetPeriodMonth, sdk.EntitlementResetAnchorCalendar, int32(80) if _, err := client.Entitlements.Create(ctx, sdk.EntitlementInput{ - Name: "Monthly orders", + Name: "Monthly Orders", Slug: &slug, Type: &number, AggregationMethod: &sum, @@ -210,7 +212,7 @@ orders, err := sdk.NumberLicenseValue(1000) if err != nil { return err } -err = client.Licenses.AssociateEntitlement(ctx, "team-v1", slug, orders, sdk.WithOveragePercent(20)) +err = client.Licenses.AssociateEntitlement(ctx, "premium-v2", slug, orders, sdk.WithOveragePercent(20)) ``` `Entitlements.Update` and `Licenses.UpdateEntitlement` replace rather than patch. A @@ -223,14 +225,14 @@ stay. ```go // Report a delta against a metered entitlement. -err := client.Instances.ReportUsage(ctx, "acme-production", "api-calls", 1) +err := client.Instances.ReportUsage(ctx, "sakura-tokyo-prod", "monthly-orders", 1) if errors.Is(err, sdk.ErrThresholdExceeded) { // Refused: usage would pass what the license grants, allowance included. } // Or set the absolute value and get the resulting metric back. -usage, err := client.Instances.ReportEntitlementUsageMetric(ctx, "acme-production", "seats", - sdk.UsageReportInput{Value: 18, Behavior: sdk.Set}, +usage, err := client.Instances.ReportEntitlementUsageMetric(ctx, "sakura-tokyo-prod", "delivery-drivers", + sdk.UsageReportInput{Value: 8, Behavior: sdk.Set}, ) ``` @@ -331,7 +333,7 @@ fetched -- without one the whole trail is walked, like any other list -- and ```go limit := int32(100) -trails, err := client.Instances.ListAuditTrails(ctx, "acme-production", &sdk.AuditTrailsOptions{ +trails, err := client.Instances.ListAuditTrails(ctx, "sakura-tokyo-prod", &sdk.AuditTrailsOptions{ EventName: "FEATURE_FLAG_EVALUATED", After: &since, Limit: &limit, @@ -348,7 +350,7 @@ paginates by cursor only. Every non-2xx response is returned as an `*sdk.Error`. ```go -instance, err := client.Instances.Get(ctx, "acme-production") +instance, err := client.Instances.Get(ctx, "sakura-tokyo-prod") if err != nil { var apiErr *sdk.Error if errors.As(err, &apiErr) { From d43e6e416ffc69474a578a166d6b9e2cd1aa91fb Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Wed, 14 Oct 2026 00:03:01 +0200 Subject: [PATCH 5/5] docs: filter the audit trail example on an event an instance records A flag evaluation is recorded for the organization, not for an instance, so filtering an instance's trail on it returns nothing on a live stack. The example filters on INSTANCE_UPDATED. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d62979..6a030e3 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ fetched -- without one the whole trail is walked, like any other list -- and ```go limit := int32(100) trails, err := client.Instances.ListAuditTrails(ctx, "sakura-tokyo-prod", &sdk.AuditTrailsOptions{ - EventName: "FEATURE_FLAG_EVALUATED", + EventName: "INSTANCE_UPDATED", After: &since, Limit: &limit, })