Skip to content

Ship an authoritative embedded tool Catalog distilled from the SDK's own spec - #926

Merged
jeremy merged 1 commit into
mainfrom
sdk/embed-catalog
Sep 18, 2026
Merged

jeremy merged 1 commit into
mainfrom
sdk/embed-catalog

Conversation

@jeremy

@jeremy jeremy commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

What

Ships an authoritative, embedded tool Catalog in the Go module — the
operation-shaped sibling of the existing url-routes.json embed. Downstream
consumers generate their tool catalog directly from the pinned SDK dependency
instead of vendoring openapi.json + behavior-model.json and re-joining them.

scripts/gen-catalog distills one self-contained go/pkg/basecamp/catalog/catalog.json
by joining, at the source:

  • openapi.json → operationId, tag, HTTP method + path, description, path/query
    params, request-body JSON schema (component $refs inlined, x-go-* codegen
    noise stripped)
  • behavior-model.json → per-operation readonly / idempotent / retry /
    pagination / write traits

Result: 396 KB vs the 1.1 MB raw openapi.json, self-contained (0 residual
$refs), 265 operations across 27 tags.

Public API (github.com/basecamp/basecamp-sdk/go/pkg/basecamp/catalog)

func Load() (*Catalog, error)        // parses the embedded catalog once, cached & shared
func Operations() ([]*Operation, error)

type Catalog struct {
    Schema, Version, ModelVersion string
    Generated  bool
    Operations []*Operation          // sorted by ID
}
func (c *Catalog) Operation(id string) (*Operation, bool)
func (c *Catalog) ByTag() map[string][]*Operation

type Operation struct {
    ID          string          // operationId, e.g. "CreateTodo"
    Tag         string          // single OpenAPI tag = domain
    Method      string          // "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
    Path        string          // "/{accountId}/buckets/{bucketId}/todos.json"
    Doc         string
    ReadOnly    bool
    Idempotent  bool
    Destructive *bool           // tri-state; nil = model does not declare it (today)
    Retry       *Retry
    Pagination  *Pagination
    Write       *WriteSemantics
    Params      []Param         // schema $refs inlined
    Body        map[string]any  // request-body JSON schema, $refs inlined; nil if none/non-JSON
    BodyMediaType string         // e.g. "multipart/form-data" for the 3 binary uploads
    BodyRequired  bool
}
func (o *Operation) Paginated() bool
func (o *Operation) IsDestructive() bool

This is a strict superset of what basecamp-mcp-server's domains.json needs per
operation, so the cutover is a mechanical field map. Product-specific concerns stay
with the consumer: tool prefix, tag→domain grouping, and the action-name spelling
(snake_case(Operation.ID)).

The design deliberately prefers a distilled artifact over embedding the raw
1.1 MB openapi.json: smaller, focused, stable, and self-contained (no components
section needed at read time). It follows the repo's existing url-routes.json
precedent — a distilled openapi.json join embedded beside the code that reads it.

Generated, not hand-maintained + drift gate

  • make catalog regenerates the artifact (also wired into make generate after url-routes).
  • make catalog-check runs the generator's unit tests, regenerates to a temp file,
    and fails unless the committed catalog.json is byte-identical. Wired into
    check-targets and its own CI step in the test-go job — because make check
    membership is not enforcement in this repo (no CI job runs the full make check),
    matching the url-routes-check / behavior-model-check posture.
  • The join is strict both ways (every operation in both files, exactly one tag),
    mirroring the toolkit loader's contract so a torn regeneration fails loudly.

scripts/gen-catalog is a stdlib-only module in go.work (like
scripts/check-wrapper-drift), so go run needs no network.

The destructive trait gap (important)

behavior-model.json carries no destructive trait today — it has only
readonly, idempotent, pagination, retry, write. The Catalog carries
Destructive *bool sourced only from that (absent) trait, so it is nil on
every operation today and a consumer keeps bridging from the action name
(mcp/catalog.BridgeDestructive). The loader surfaces the real trait the moment the
model grows one, retiring the bridge.

It is deliberately not derived from the HTTP method. Empirically, neither method
nor name is authoritative here:

  • 9 DELETE-method operations are reversible toggles, not destructive:
    UncompleteTodo, UnpinMessage, UnspotlightRecording, DeprioritizeAssignment,
    DisableTool, DisableOutOfOffice, DisableProjectClients, DisableCardColumnOnHold,
    ResumeQuestion. "DELETE == destructive" would misclassify all nine.
  • One genuinely destructive operation, TrashRecording, is a PUT — a method rule
    misses it.

So destructive is a curated semantic judgment that belongs in the Smithy model,
not a derived rule. Smallest upstream fix (a focused follow-up, kept out of this PR
to stay additive and clear of in-flight spec work):

  1. Add a marker trait basecampDestructive to spec/basecamp-traits.smithy.
  2. Apply it to the ~20–30 genuinely-destructive operations in spec/basecamp.smithy
    (the semantic judgment — trash/delete/purge/archive that lose or hide data, not
    reversible toggles).
  3. One line in scripts/generate-behavior-model:
    destructive: (if has_trait("basecamp.traits#basecampDestructive") then true else null end).

Then make behavior-model + make catalog flow the trait through with zero code
change here
— the plumbing already reads it — and basecamp-mcp-server's
BridgeDestructive heuristic and its tripwire test retire.

Coordination

Built on origin/main. Additive to the generate pipeline; does not touch
scripts/check-required-tags.rb, spec/overlays/tags.smithy, or the recordings
retag (in flight on sdk/tag-events-recordings, #922). Once #922 merges, this
catalog should be regenerated (make catalog) so it reflects the corrected
Recordings/EventFeed tags — and consumers get the corrected tags for free from the
pinned dependency, no consumer change needed.

Includes all 265 operations, including the 3 binary-upload operations
(CreateAttachment, CreateCampfireUpload, UpdateAccountLogo) flagged via
BodyMediaType (they carry no JSON body). A consumer that wants only JSON-body/bodyless
operations filters BodyMediaType == "".

Verification

  • go build ./... (go module), go vet ./..., gofmt clean
  • go test ./pkg/basecamp/catalog/ and go test ./scripts/gen-catalog/ pass
  • make catalog-check green; regeneration is byte-identical; drift gate verified
    to fail on a mutated artifact
  • No existing exports touched; new package only

Summary by cubic

Ships an authoritative, embedded tool Catalog in the Go module so downstream consumers get all operations and traits from one self-contained file instead of vendoring and re-joining openapi.json and behavior-model.json.

  • Adds go/pkg/basecamp/catalog with a typed loader (Load, Operations) and a Catalog/Operation model covering method, path, tags, readonly/idempotent traits, retry, pagination, write semantics, params, and body schemas with $refs inlined.
  • Adds scripts/gen-catalog, a stdlib-only generator wired into go.work, that distills a 396 KB catalog.json (265 operations, 27 tags) from the two model files.
  • Adds make catalog and make catalog-check; the check fails unless the committed artifact is byte-identical to a fresh generation and is wired into check-targets plus a dedicated CI step.

Behavior notes

  • The join is strict both ways: any operation missing from either file, carrying zero/multiple/blank tags, or on a verb outside the generated GET/PUT/POST/DELETE/PATCH set aborts generation (non-generated verbs reference Generators iterate a five-verb list, so an operation on any other HTTP method is silently dropped from every SDK #925 and are registered in spec/tracking-issues.yml).
  • The catalog is framed as the tag-based operation view, not a claim about any per-language SDK's internal service grouping.
  • Operation.Destructive is nil until the behavior model declares the trait; it is not derived from the HTTP method because nine DELETE operations are reversible toggles and one destructive operation is a PUT, so consumers keep bridging from action names until then.

Written for commit 5ec145a. Summary will update on new commits.

Review in cubic


Update: catalog framed as the tag view, generation-time invariants hardened

Per review from the #922/#925 tag-pipeline owner:

New generator tests cover each invariant: non-generated-verb rejection (#925), blank/multi/zero-tag rejection, and structural-path-key exclusion.

Inherited CI red (not this change): main is red at ebe5fd5 on Known-defect tracking issues are open because #922 added a tracked in #925 comment to scripts/check-required-tags.rb without registering its row; #927 is the one-row fix. Every open PR inherits it. This PR's own #925 registry row is consistent (registered count = swept count = 1).

@github-actions github-actions Bot added github-actions Pull requests that update GitHub Actions go labels Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/test.yml

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

@jeremy
jeremy marked this pull request as ready for review September 18, 2026 07:18
Copilot AI balanced review requested due to automatic review settings September 18, 2026 07:18

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.

🟡 Changes recommended

The generator accepts blank tags, allowing operations to be emitted without a usable domain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a generated, embedded Go catalog combining OpenAPI operations with behavioral metadata.

Changes:

  • Adds the catalog generator, artifact, loader, and tests.
  • Integrates generation and drift checks into Make and CI.
  • Exposes typed operation lookup and grouping APIs.

[!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
scripts/gen-catalog/main.go Generates the catalog from source artifacts.
scripts/gen-catalog/main_test.go Tests joins, references, and invariants.
scripts/gen-catalog/go.mod Defines the generator module.
go/pkg/basecamp/catalog/catalog.go Provides the public catalog API.
go/pkg/basecamp/catalog/catalog_test.go Tests loading and catalog integrity.
go/pkg/basecamp/catalog/catalog.json Contains the generated embedded catalog.
Makefile Adds generation and drift-check targets.
go.work Registers the generator module.
.github/workflows/test.yml Enforces catalog freshness in CI.
Review details
  • Files reviewed: 8/9 changed files
  • Comments generated: 1
  • Review effort level: Balanced

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

Comment thread scripts/gen-catalog/main.go

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aeb6f28528

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/pkg/basecamp/catalog/catalog.json Outdated
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-18T07:44:35.548708Z 5ec145a New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added the spec Changes to the Smithy spec or OpenAPI label Sep 18, 2026
…own spec

Downstream tool-catalog consumers (basecamp-mcp-server and any other) vendor
openapi.json + behavior-model.json and re-join them at startup to derive their
per-operation tool surface. That snapshot drifts and duplicates a join the SDK
is best placed to do once, at the source.

This adds a distilled, versioned, embedded Catalog to the Go module — the
operation-shaped sibling of the existing url-routes.json embed. scripts/gen-catalog
joins openapi.json (identity, wire shape, request-body schemas) with
behavior-model.json (readonly/idempotent/retry/pagination/write traits) into one
self-contained go/pkg/basecamp/catalog/catalog.json: request-body $refs inlined,
x-go-* codegen noise stripped, 396 KB versus the 1.1 MB raw openapi.json. The new
pkg/basecamp/catalog package embeds it and exposes a typed loader
(catalog.Load, Operations) over Catalog/Operation structs carrying everything a
per-operation tool needs. Consumers read it from the pinned dependency instead of
vendoring anything.

The join is strict both ways (every operation in both files, exactly one tag),
matching the toolkit loader's contract, so a torn regeneration fails loudly. A
make catalog target regenerates the artifact and a make catalog-check gate — wired
into check-targets and its own CI step — fails when the committed catalog is not
byte-identical to a fresh distillation, matching the url-routes/behavior-model
posture.

Destructive is carried as a tri-state trait sourced only from the behavior model,
which does not declare it yet, so it is absent today and a consumer keeps bridging
from the action name. It is deliberately not derived from the HTTP method: Basecamp
serves nine reversible toggles over DELETE (UncompleteTodo, UnpinMessage,
DisableTool, ...) and one genuinely destructive operation (TrashRecording) over PUT,
so a method rule would be wrong both ways. Destructive belongs in a curated Smithy
trait; the loader surfaces it the moment the model grows one.
@jeremy
jeremy merged commit 50f89c6 into main Sep 18, 2026
51 checks passed
@jeremy
jeremy deleted the sdk/embed-catalog branch September 18, 2026 07:44

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ec145aff3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Makefile
generate:
@$(MAKE) smithy-build
@$(MAKE) behavior-model url-routes provenance-sync
@$(MAKE) behavior-model url-routes catalog provenance-sync

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize catalog generation after the behavior model

When make -j generate follows a Smithy change that affects behavior traits, this sub-make may schedule catalog concurrently with behavior-model; the catalog generator can therefore read the previous behavior-model.json before the atomic replacement completes and leave catalog.json stale, despite the surrounding comment promising ordered phases. Put catalog in a subsequent sub-make or add an explicit dependency so it cannot start until behavior-model finishes.

AGENTS.md reference: AGENTS.md:L123-L132

Useful? React with 👍 / 👎.

jeremy added a commit that referenced this pull request Sep 19, 2026
- Cite #935 (policy transcribed once per language), not #926 (the merged
  catalog PR), for the 'generators can never disagree' argument; keep #925
  as the original failure.
- Fix the loadGeneratedVerbs rationale: no upstream validator exists (#933
  closed unmerged and proved a 'prerequisite of every generate target'
  unreachable), so fail-loud IS the contract — not a reason to relax the read.
- Drop the nonexistent 'make check-generated-verbs' target from the
  no-usable-verb error; point at the verbs array in the file instead.
- Say why defaultGeneratedVerbs is a constant, not a flag (an overridable
  bound produced two of #933's findings).
jeremy added a commit that referenced this pull request Sep 20, 2026
)

* Derive gen-catalog's emission bound from spec/generated-verbs.json

gen-catalog carried its own five-verb allow-list — a known copy of the
same fact six SDK generators already read from spec/generated-verbs.json,
which is exactly the divergence #925 was. Replace the hardcoded map with a
permissive derive from that single declaration (basecamp-sdk#926):

- loadGeneratedVerbs reads the file with encoding/json, keeps the
  non-empty/non-whitespace verb strings, and FAILS LOUD on an unreadable
  or unparseable file AND on a file that yields no usable verb (verbs
  absent, [], or all blank). No fallback to a hardcoded default: a silent
  fallback is how a stale superset (e.g. one still naming patch) survives
  the file dropping it, so an empty bound stops the build rather than
  reading as "no restriction." No downstream len==0 short-circuit turns an
  empty bound into allow-everything.
- The read is one isolated function so the #935 switch to a generated Go
  constant is a one-function swap, not a rewrite.
- Build takes the bound as a parameter; the exclusion-based path-item slot
  discovery and the fail-loud-on-out-of-bound-verb behavior are unchanged,
  now bounded by the file rather than the literal.

Post-#932 the file no longer lists patch, so the derived bound differs
from the old literal only by patch (unused) and catalog.json regenerates
byte-identical.

Also fixes the stale header comment (it still named the five hardcoded
verbs) and updates the not_patch note in generated-verbs.json, whose last
sentence described gen-catalog as carrying a superset — now false, since
the bound is identical by construction.

* Correct gen-catalog derive comments and no-verb error

- Cite #935 (policy transcribed once per language), not #926 (the merged
  catalog PR), for the 'generators can never disagree' argument; keep #925
  as the original failure.
- Fix the loadGeneratedVerbs rationale: no upstream validator exists (#933
  closed unmerged and proved a 'prerequisite of every generate target'
  unreachable), so fail-loud IS the contract — not a reason to relax the read.
- Drop the nonexistent 'make check-generated-verbs' target from the
  no-usable-verb error; point at the verbs array in the file instead.
- Say why defaultGeneratedVerbs is a constant, not a flag (an overridable
  bound produced two of #933's findings).
@jeremy jeremy mentioned this pull request Sep 20, 2026
jeremy added a commit that referenced this pull request Sep 20, 2026
11 version files and 8 lockfiles, written by scripts/bump-version.sh.
MIGRATING.md's '# Unreleased' section is promoted to '# v0.20.0'.

Thirteen commits since v0.19.0 (2026-09-16), the substantial ones being
the #925 chain — #931 finding operations by exclusion so no HTTP verb can
drop one in silence, #932 stopping a refusal from destroying generated
output, and #938 deriving gen-catalog's emission bound from the shared
declaration — plus #926's embedded tool Catalog, #930's Automation split,
#924's event-feed body validation at the wrapper's decode, and #937's
anyio bump for CVE-2026-63374.

Ran the release's own preflight here rather than discovering it at tag
time: promote-migrating --check passes, rs-publish-check packages and
dry-run-uploads 0.20.0 clean, and all four lockfile freshness assertions
hold.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

github-actions Pull requests that update GitHub Actions go spec Changes to the Smithy spec or OpenAPI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants