Skip to content

Swift and Rust refuse person shapes the reference writes back - #916

Merged
jorgemanrubia merged 2 commits into
mainfrom
swift-rust-person-decode
Sep 16, 2026
Merged

jorgemanrubia merged 2 commits into
mainfrom
swift-rust-person-decode

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 16, 2026

Copy link
Copy Markdown
Member

A merge-safe update in Swift or Rust failed on a to-do the reference updates without complaint. When a person in assignees, completion_subscribers or a schedule entry's participants has no id, or the list holds a null element, Go reads it as 0 or the zero person and sends that back. Swift and Rust threw at decode and the update never happened. The same refusal hit plain reads like todos.get.

Originally tracked in Kotlin, Swift and Rust decoders refuse person shapes the reference writes back.

Follows Merge-safe writes refuse assignee ids the API really sends, which closed the same two shapes in Python, Ruby and TypeScript at their readers, and Seven SDKs read a person id seven ways, and Go disagreed with itself, which settled the id grammar.

Why the generators

Swift and Rust have no reader of their own on the write path. The generated model is the reader, so both refusals come from how the model is generated: a non-optional id with no default, and a list whose element type cannot be null. The fix belongs in the model generators, the same way the card predicted.

What changed

  • A person is a generated struct whose id is a required flexible id. The generators key on Go's FlexibleInt64 marker, not on the type's name. That reaches exactly what Go's flexible decoder reaches. UpcomingSchedulePerson, MyAssignmentAssignee, OutOfOfficePerson and TemplateLibraryConfirmationPerson have a plain int64 id in Go, so they are not persons here and stay strict. Tests in both SDKs check that.
  • If a person has no id, it reads as 0. An explicit "id": null still fails the read, as it does in Go.
  • A null element in a member whose element type is a person reads as the zero person. A non-object element still fails, and a null list is still no list.

Rust makes this change in its serde attributes. Swift makes it in explicit coding for the 11 models involved. Every other model keeps its synthesized Codable. SPEC §10 now describes the state of Swift and Rust.

Divergence from the reference

A throwaway Go oracle ran the reference's own Update, EditEntry, Get and People.List over 56 wire shapes at five sites: todo assignee_ids, completion_subscriber_ids, schedule entry participant_ids (write), todos.get assignees and people.list (read). The corpus is card 42's 39 shapes plus rows for a missing id, a null element and float spellings. Swift, Rust and Kotlin were measured on the same corpus through their real services: Rust with cargo, Kotlin with gradlew --offline on the JVM, and Swift with swift test on Linux in the swift:6.1 container (the whole suite: the same failures as main there, plus the five new tests passing). The macOS job in CI is the authority for Darwin.

SDK rows diverging (of 56) site × row pairs (of 279)
Rust, main 10 46
Rust, this PR 7 15
Swift, main 20 96
Swift, this PR 17 65
Kotlin, main (unchanged) 10 46

(Kotlin is measured only. Its generator is left to be done together with card 43's quoted-number fix in the same generator, so the two changes don't collide.)

Closed in Swift and Rust: missing id (3 rows), and null elements at every generated member.

Still divergent, on purpose:

  • Null elements in a top-level people list (people.list and its siblings). The generic page reader decodes these, not a generated member. The only generator-level way to reach them is to make a person decode from null everywhere. That would also read "creator": null on 35 required members as the system actor. Go does read it that way, but that is a decision to make, not a side effect to slip in.
  • Missing or null name ({"id":7}, {}, and "name": null, which the corpus does not cover). Go reads both as "". It is a required member, not the id, so it falls under SPEC §10's required-member rule and was not loosened here.
  • Swift float spellings: 1024.0, 1e3, 7.00, 1000e-3, and 0.0/-0.0, which read as the system actor. Go fails the read. These are unreachable through Decoder. Decimal doesn't help either: measured on Swift 6.1, 1024.0 and 1024 decode to the same Decimal. Closing this needs a pre-decode pass over the raw bytes. FlexibleInt's doc used to say the residual could never produce the system actor, and it now says 0.0 does.
  • null body for a list (people.list with the body null): Go returns an empty list. That is list-body handling, not person decoding.
Row by row (a named site means only that site diverges)
wire shape of the element Go Rust main Rust PR Swift main Swift PR Kotlin main
number [1049715914] ok ok ok ok ok
numeric string, no personable_type [1049715914] ok ok ok ok ok
numeric string, with personable_type [1049715914] ok ok ok ok ok
leading zeros string [7] ok ok ok ok ok
negative string [-5] ok ok ok ok ok
plus-signed string [5] ok ok ok ok ok
non-numeric sentinel [0] ok ok ok ok ok
non-numeric sentinel w/ personable_type [0] ok ok ok ok ok
overflow string error ok ok ok ok ok
int64 max string [9223372036854775807] ok ok ok ok ok
empty string [0] ok ok ok ok ok
whitespace string [0] ok ok ok ok ok
hex string [0] ok ok ok ok ok
float-ish string [0] ok ok ok ok ok
underscore string [0] ok ok ok ok ok
fullwidth digit string [0] ok ok ok ok ok
null id error ok ok ok ok ok
missing id [0] x ok x ok x
bool id error ok ok ok ok ok
non-integral number error ok ok ok ok ok
integral float number error ok ok x x ok
number beyond int64 error ok ok ok ok ok
object id error ok ok ok ok ok
array id error ok ok ok ok ok
element not an object error ok ok ok ok ok
element null [0] x x list x x list x
two elements, second bad [7,0] ok ok ok ok ok
empty array [] ok ok ok ok ok
int64 min string [-9223372036854775808] ok ok ok ok ok
syntax refusal after uint64 max digits [0] ok ok ok ok ok
range refusal before the bad byte error ok ok ok ok ok
uint64-range string (above int64 max) error ok ok ok ok ok
number past 2^53 (inside int64) [9007199254740993] ok ok ok ok ok
exponent number error ok ok x x ok
exponent string [0] ok ok ok ok ok
string past 2^53 (inside int64) [9007199254740993] ok ok ok ok ok
key is null [] x list x list x list x list x list
key is not an array error ok ok ok ok ok
key absent [] ok ok ok ok ok
missing id, with personable_type [0] x ok x ok x
missing id, second of two [7,0] x ok x ok x
empty object element [0] x x x x x
missing name only [7] x x x x x
null id, with personable_type error ok ok ok ok ok
null element, first of two [0,7] x x list x x list x
null element, last of two [7,0] x x list x x list x
two null elements [0,0] x x list x x list x
float zero error ok ok x x ok
float negative zero error ok ok x x ok
exponent zero power error ok ok x x ok
capital exponent error ok ok x x ok
fractional mantissa exponent error ok ok x x ok
trailing zeros fraction error ok ok x x ok
negative exponent integral error ok ok x x ok
float past int64 error ok ok ok ok ok
float with personable_type error ok ok x x ok
The Go oracle (throwaway, not committed)
package basecamp

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"
)

// Throwaway oracle (card 45): what the reference does with each person shape, at every
// site measured in the ports. Output lines: site \t label \t verdict.
const card45Dir = "<scratch>"

type card45Case struct{ Label, Value string }

func card45Cases(t *testing.T) []card45Case {
	raw, err := os.ReadFile(card45Dir + "/cases.json")
	if err != nil {
		t.Fatal(err)
	}
	var cs []card45Case
	if err := json.Unmarshal(raw, &cs); err != nil {
		t.Fatal(err)
	}
	return cs
}

func card45Splice(tmpl string, holders [][2]string, value string) string {
	out := tmpl
	for _, h := range holders {
		if value == "ABSENT" {
			member := fmt.Sprintf("%q: %s", h[1], h[0])
			out = strings.ReplaceAll(out, member+", ", "")
			out = strings.ReplaceAll(out, ", "+member, "")
		} else {
			out = strings.ReplaceAll(out, h[0], value)
		}
	}
	return out
}

func card45Ids(v any) string {
	j, _ := json.Marshal(v)
	return string(j)
}

func card45Server(t *testing.T, get, put string, putBody *map[string]any) *AccountClient {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.Method == http.MethodPut {
			dec := json.NewDecoder(r.Body)
			dec.UseNumber()
			_ = dec.Decode(putBody)
			w.Write([]byte(put))
			return
		}
		w.Write([]byte(get))
	}))
	t.Cleanup(server.Close)
	cfg := DefaultConfig()
	cfg.BaseURL = server.URL
	return NewClient(cfg, &StaticTokenProvider{Token: "t"}).ForAccount("999")
}

func TestCard45Oracle(t *testing.T) {
	todoT, _ := os.ReadFile(card45Dir + "/todo_tmpl.json")
	entryT, _ := os.ReadFile(card45Dir + "/entry_tmpl.json")
	var lines []string
	emit := func(site, label, verdict string) { lines = append(lines, site+"\t"+label+"\t"+verdict) }
	ctx := context.Background()
	for _, c := range card45Cases(t) {
		th := [][2]string{{"__A__", "assignees"}, {"__S__", "completion_subscribers"}}
		// write: todo update
		{
			put := map[string]any{}
			acct := card45Server(t, card45Splice(string(todoT), th, c.Value), card45Splice(string(todoT), th, "[]"), &put)
			_, err := acct.Todos().Update(ctx, 456, &UpdateTodoRequest{Content: "x"})
			if err != nil {
				emit("todo.update assignee_ids", c.Label, "ERROR")
				emit("todo.update completion_subscriber_ids", c.Label, "ERROR")
			} else {
				emit("todo.update assignee_ids", c.Label, card45Ids(put["assignee_ids"]))
				emit("todo.update completion_subscriber_ids", c.Label, card45Ids(put["completion_subscriber_ids"]))
			}
		}
		// write: schedule entry edit
		{
			eh := [][2]string{{"__P__", "participants"}}
			put := map[string]any{}
			acct := card45Server(t, card45Splice(string(entryT), eh, c.Value), card45Splice(string(entryT), eh, "[]"), &put)
			_, err := acct.Schedules().EditEntry(ctx, 1069479523, func(f *ScheduleEntryFields) error {
				f.SetParticipantIDs(f.ParticipantIDs())
				return nil
			})
			if err != nil {
				emit("entry.edit participant_ids", c.Label, "ERROR")
			} else {
				emit("entry.edit participant_ids", c.Label, card45Ids(put["participant_ids"]))
			}
		}
		// read: todo get assignees
		{
			put := map[string]any{}
			acct := card45Server(t, card45Splice(string(todoT), th, c.Value), "{}", &put)
			todo, err := acct.Todos().Get(ctx, 456)
			if err != nil {
				emit("todo.get assignees", c.Label, "ERROR")
			} else {
				ids := []int64{}
				for _, p := range todo.Assignees {
					ids = append(ids, p.ID)
				}
				emit("todo.get assignees", c.Label, card45Ids(ids))
			}
		}
		// read: people list, the corpus value as the whole body
		if c.Value != "ABSENT" {
			put := map[string]any{}
			acct := card45Server(t, c.Value, "{}", &put)
			res, err := acct.People().List(ctx, nil)
			if err != nil {
				emit("people.list", c.Label, "ERROR")
			} else {
				ids := []int64{}
				for _, p := range res.People {
					ids = append(ids, p.ID)
				}
				emit("people.list", c.Label, card45Ids(ids))
			}
		}
	}
	out := os.Getenv("CARD45_OUT")
	if out == "" {
		out = card45Dir + "/go.tsv"
	}
	os.WriteFile(out, []byte(strings.Join(lines, "\n")+"\n"), 0o644)
}

Tests

Rust: tests/person_shapes.rs and a unit test for the required-list helper. Swift: PersonShapesTests. Each covers an absent id, a null id, null elements, what stays strict, and the merge-safe update sending [0, 0, 7]. Every test was run red:

  • against main's generated models, the lenient-direction tests fail;
  • with element leniency widened past null, only_the_element_is_lenient fails;
  • with a null id read as 0, the null-id tests fail;
  • with the person marker widened to a *Person name match, the strict-person test fails in both (UpcomingSchedulePerson), and also in Swift for *Assignee.

The generators' branch for a required-and-nullable person list has no model to exercise it today, so it has never been compiled. Rust also passes make rs-check (except cargo-deny, which isn't installed locally), drift, and conformance (302/302). A Linux regeneration of the Swift models changed only the 11 intended files.

An absent person id and a null element of a person list decode to 0 and
the zero person in Go, and a merge-safe write sends them back. Swift and
Rust refused both at the generated model. Their generators now key a
person on the required flexible id, default an absent id to 0 (an
explicit null still fails), and read a null element of a person list as
the zero person. Plain-int64 person types stay strict.
Copilot AI balanced review requested due to automatic review settings September 16, 2026 14:49
@github-actions github-actions Bot added swift rust Rust SDK labels Sep 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

Generator changes preserve strict boundaries while comprehensive regression tests cover the intended decoding and write-back behavior.

Pull request overview

Aligns Swift and Rust person decoding with Go for missing IDs and null list elements.

Changes:

  • Updates generators and decoding helpers for zero-person semantics.
  • Regenerates affected Swift and Rust models.
  • Adds regression tests and documents remaining divergences.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
SPEC.md Documents decoding behavior and intentional divergences.
swift/Tests/BasecampTests/PersonShapesTests.swift Tests Swift reads and merge-safe writes.
swift/Sources/BasecampGenerator/ModelEmitter.swift Generates person-specific Codable implementations.
swift/Sources/Basecamp/PersonList.swift Adds null-element person-list decoding.
swift/Sources/Basecamp/FlexibleInt.swift Updates missing-ID and float-divergence documentation.
swift/Sources/Basecamp/Generated/Models/Todo.swift Applies person-list decoding.
swift/Sources/Basecamp/Generated/Models/Subscription.swift Applies person-list decoding.
swift/Sources/Basecamp/Generated/Models/SearchResult.swift Applies person-list decoding.
swift/Sources/Basecamp/Generated/Models/ScheduleEntry.swift Applies participant decoding.
swift/Sources/Basecamp/Generated/Models/ProjectAccessResult.swift Applies access-list decoding.
swift/Sources/Basecamp/Generated/Models/Person.swift Defaults missing IDs and defines the zero person.
swift/Sources/Basecamp/Generated/Models/Notification.swift Applies participant decoding.
swift/Sources/Basecamp/Generated/Models/CardTable.swift Applies subscriber decoding.
swift/Sources/Basecamp/Generated/Models/CardStep.swift Applies assignee decoding.
swift/Sources/Basecamp/Generated/Models/CardColumn.swift Applies subscriber decoding.
swift/Sources/Basecamp/Generated/Models/Card.swift Applies assignee and subscriber decoding.
rust/generator/tests/fixtures/mini/expected/types.rs Updates generator golden output.
rust/generator/src/emit/types.rs Emits missing-ID defaults and person-list deserializers.
rust/basecamp-sdk/tests/person_shapes.rs Tests Rust reads and merge-safe writes.
rust/basecamp-sdk/src/types.rs Adds shared person-list deserialization helpers.
rust/basecamp-sdk/src/generated/types.rs Regenerates affected Rust model attributes.
Review details
  • Files reviewed: 9/21 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

A null person list is checked as None rather than through a default, and
the Swift strict-person test now reaches UpcomingSchedulePerson and
OutOfOfficePerson, so widening the marker to a name match fails it. SPEC
names TemplateLibraryConfirmationPerson among the strict types.
@jorgemanrubia
jorgemanrubia merged commit 64457f1 into main Sep 16, 2026
50 checks passed
@jorgemanrubia
jorgemanrubia deleted the swift-rust-person-decode branch September 16, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants