Correct the model where Fizzy's controllers disagree with it

Merged
JE
14d ago
+1058-1072

Corrects spec/fizzy.smithy for the tools the controller sweep on basecamp/fizzy-mcp-server#9 found disagreeing with Fizzy at 27d0d2e. The flat catalog in #9 is generated from this model, so the fix belongs here rather than in a growing flat.Corrections table. I checked every row against the controller, model or concern at that commit before changing the spec. Everything else is regenerated: openapi.json, behavior-model.json, URL routes, and all six SDKs (Go, TypeScript, Ruby, Kotlin, Swift, Rust).

Per tool

The sweep table names 30 tools by name. "Entropy periods" adds create_board, which is 31. I covered all of them. Where the table's "..." pointed at more list_cards parameters, I documented those too.

ToolSweep findingChange
list_notificationsignores read@httpQuery("read") removed. The operation doc says it takes no filter: up to 100 unread, then a page of read (NotificationsController#index)
create_card, update_cardimage needs an uploadDocumented, not removed. image is the signed blob id from CreateDirectUpload, which the SDK has and fizzy-cli uses (card create/update --image). Removing it would break a working SDK path. Only MCP can't upload, and it already excludes CreateDirectUpload
assign_card, self_assign_card, tag_cardtogglesOperation docs say they toggle, that both outcomes return 204, and that a repeat reverses the first call. tag_card also creates the tag when it's missing
postpone_card, triage_card, un_triage_cardnot idempotent: each call records an event@fizzyIdempotent removed, and @idempotent too on the DELETE. Retry is maxAttempts: 1. The doc says each call notifies webhooks and adds a system comment
unpin_cardnot idempotent: a retry hits nil.destroy@idempotent and @fizzyIdempotent removed, maxAttempts: 1. The doc records the 500 on a card that isn't pinned (I inferred that from unpin_by and didn't run it)
update_board_involvementinvolvement required@required. Values access_only or watching documented
update_username required@required
update_account_settingsname required@required
update_account_entropy, update_board_entropyperiod required, values unchecked@required. Values 3, 7, 11, 30, 90 or 365 documented (Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS; 11 really is in the list)
create_board, update_boardentropy periodsSame values documented on auto_postpone_period_in_days
update_join_codeusage_limit required@required. The doc notes that Fizzy accepts up to 10,000,000,000 but the field is 32-bit. Widening it to Long would break fizzy-cli's int32 assignment, so it isn't done here
update_notification_settingsbundle_email_frequency required@required. Values never, every_few_hours, daily or weekly documented
update_user_roleany other role demotes to memberValues member or admin documented, along with the fact that anything else, owner included, sets member
create_webhook, update_webhookunknown actions droppedThe 11 Webhook::PERMITTED_ACTIONS documented. Also noted: unknown actions drop silently, a webhook with none gets no deliveries, and an update replaces the list
create_column, update_columncolor valuesThe nine Color::COLORS CSS values documented. Any other value is stored but reads back as Blue
list_cardsindexed_by, sorted_by, ...Values and fallbacks documented for indexed_by, sorted_by, assignment_status, creation and closure
mark_card_read, mark_card_unreadsucceed without a notificationNo-op documented
move_column_left, move_column_rightno-op at the edgeNo-op documented
update_my_timezoneunknown zone acceptedDocumented: it's stored, returns 204, and reads back as UTC
update_board"at least one field", 400 on user_ids alone, user_ids replaces membershipAll three documented
update_card, update_column, update_step, update_webhook"at least one field"Documented (Rails param wrapping makes an empty body {key: {}}, which params.expect rejects with 400)

Not spec changes: these belong in Fizzy

These three are documented here as current behavior, but the fix is a Fizzy change:

  • update_board with only user_ids returns 400, because BoardsController#board_params expects a board object that user_ids isn't wrapped into.
  • unpin_card on a card that isn't pinned returns 500 (pins.find_by(...).tap { it.destroy }).
  • Toggle-on-create: POST .../assignments, .../self_assignment and .../taggings toggle. An idempotent add and remove would need new endpoints or a parameter in Fizzy.

The earlier flat.Corrections entries

Before the sweep, #9 had already hand-corrected 11 operations. Each one was checked against a controller, so I fixed them here too. Without that, a release and resync would retire none of flat.Corrections:

  • CreateCard: board_id is @required. column_id, assignee_ids and tag_names are removed, because card_params permits none of them.
  • UpdateCard: column_id removed. MoveCard: column_id removed. UpdateWebhook: url removed (webhook_params.except(:url)), with the reason in the operation doc.
  • TriageCard: column_id is @required.
  • MoveColumnLeft, MoveColumnRight and ResetJoinCode are no longer idempotent, and retry is maxAttempts: 1.

I left three unfixed in the model, because the fix changes a generated method signature that fizzy-cli calls:

  • ListWebhooks and ListBoardAccesses are paged by Fizzy. Adding @fizzyPagination changes the Go service to List(ctx, boardID, path) and ListBoardAccesses(ctx, boardID, path), and fizzy-cli calls both with the old arguments. ListWebhooks now documents that Fizzy pages it.
  • BulkReadNotifications: removing the ignored notification_ids changes Go BulkRead(ctx, req) to BulkRead(ctx), and fizzy-cli calls BulkRead(ctx, nil). The field is now documented as ignored.

After this is released and resynced into fizzy-mcp-server, 8 of the 11 flat.Corrections entries stop changing anything, so Load fails until each one is deleted. Those are CreateCard, UpdateCard, MoveCard, MoveColumnLeft, MoveColumnRight, ResetJoinCode, UpdateWebhook and TriageCard. That leaves ListWebhooks, ListBoardAccesses and BulkReadNotifications. Two notes for that resync:

  • The flat package builds tool descriptions without the operation description (describe()). The new operation docs (toggles, no-ops, at-least-one-field, UpdateWebhook's URL note) only reach tool descriptions once describe() appends op.Description. Member docs already reach the input schemas.
  • None of the sweep rows ever became corrections, so for them the resync just adds information.

Enums: documented values, not enum shapes

I tried real Smithy enum/intEnum shapes first. oapi-codegen turns them into named Go types (type AssignableUserRole string, type AutoPostponePeriod int32) plus package-level constants (Admin, Member, N3, ...). fizzy-cli assigns plain variables to these fields (Role: userRoleRole, Involvement: ..., BundleEmailFrequency: ..., SubscribedActions: []string, AutoPostponePeriodInDays: int32(...)), so it would stop compiling. TypeScript would turn them into literal unions, with the same break for string variables.

So I kept every type as it was and listed the accepted values, and what happens to any other value, in the member's @documentation. That text reaches OpenAPI description, the generated doc comments in every language, and the MCP input schemas. Moving to real enums is a breaking change for fizzy-cli and is left for a separate decision.

Breaking changes for SDK consumers

This is 0.x, but every change is listed here. Each one is a call that already failed or did nothing at runtime.

  • Removed request members: CreateCard's column_id, assignee_ids and tag_names; UpdateCard's and MoveCard's column_id; UpdateWebhook's url; ListNotifications's read. Fizzy ignores all of them, so a caller that set one gets a compile error instead of a silent no-op. In Go, generated.ListNotificationsParams is gone. In Kotlin, ListNotificationsOptions is gone (list takes PaginationOptions). In Swift, ListNotificationOptions loses read:. In Rust, ListNotificationsParams is gone and notifications().list() takes no params.
  • Newly required members: CreateCard.board_id, TriageCard.column_id, and the seven update fields above. Go keeps the types but drops omitempty, so a zero value is now sent. Ruby gets new required keywords. TS, Kotlin and Swift parameters become non-optional, and Rust goes from Option<T> to T. A caller that omitted one was getting a 400.
  • Retries: PostponeCard, TriageCard, UnTriageCard, UnpinCard, MoveColumnLeft, MoveColumnRight and ResetJoinCode are no longer retried on 429/500/503, because a resend duplicates their effect.

fizzy-cli (the first-party Go consumer, pinned to v0.2.4) builds, vets and passes go test ./... against this branch (replace to this worktree, not committed). fizzy-mcp-server vendors the JSON model and doesn't import the Go module. fizzy-android doesn't use this SDK.

Tests

  • Conformance (conformance/tests/idempotency.json): the MoveColumnLeft "retries on 503" case is reversed to "does NOT retry". Each of the other six operations whose retry policy changed gets its own no-retry case: MoveColumnRight, PostponeCard, TriageCard, UnTriageCard, UnpinCard and ResetJoinCode. The CreateCard fixtures now carry board_id and assert that it's sent. Removing the TypeScript mapping makes both fail. All 134 cases pass in all six runners.
  • Runners: TS gains PostponeCard and UnpinCard. The Swift, Kotlin, Ruby and TS runners are updated for the removed and required members. Swift gains int32Param.
  • Smithy's HttpMethodSemantics.MissingIdempotentTrait warning is suppressed on the three DELETEs that aren't idempotent, and the operation doc of each says why.

CI failures that come from main

These fail on this PR without touching anything it changes:

  • Rubric Check: rubric-audit.json is dated 2026-09-09, which is more than 30 days ago.
  • Rust Deny / cargo deny: RUSTSEC-2026-0346 through 0349 in rustls, in rust/Cargo.lock, which this PR doesn't change.
  • Go Vulnerability Check / Trivy (Go): Go 1.26.6 stdlib advisories (net/http, crypto/tls, net/textproto).

Refreshing the audit, bumping dependencies and bumping the toolchain each need their own PR. I left them out of a model correction.

Not changed

  • spec/api-provenance.json still says 08395fa. This PR audits the existing operations against 27d0d2e, but it isn't a full sync. Fizzy added routes and docs between the two commits (resource :transfer_token, the sole-owner 422 on user deactivation in docs/api/sections/users.md). Moving the revision forward would hide them from make sync-status.
  • No release.

Context: basecamp/fizzy-mcp-server#9 lands as it is, since the flat catalog is opt-in (--catalog flat). The resync after this releases is what retires the corrections.