Repository navigation
feat(datasource intercom): read-only Intercom datasource, phase 1 - #387
Open
christophebrun-forest wants to merge 41 commits into
Open
christophebrun-forest wants to merge 41 commits into
christophebrun-forest wants to merge 41 commits into
Conversation
PRD-1112, first lot of the Intercom datasource. Six read-only collections: conversations and tickets on the cursor tier, admins, teams, ticket types and ticket states read whole. Rows, record details, exact counts and the conversation thread work. Server-side filtering (lot 2), writes and actions (lot 3), contacts and companies (lot 4) are refused with a message naming the lot that will answer them, rather than approximated. 254 specs, 100% line coverage, rubocop clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ables (lot 2) (#384) * fix(intercom): page id lookups and report explicit sorts Two findings from the lot 1 review, both places where a cursor collection answers something other than what it was asked for. An `id in [...]` read returned every record it fetched whatever page was asked for, so page 1 and page 2 of a related-record list rendered the same rows. The window is now cut out of the ids before they are read, which also spares the requests the discarded records cost: Intercom reads them one request each. `default_pk_sort?` read a symbol-keyed `false` as an absent key, so an explicit `?sort=-id` was taken for the ascending default the agent injects, and the warning about Intercom silently ignoring a sort never fired. Refs PRD-1118 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…385) * feat(datasource): intercom relations of the reference tier Declares the relations between the collections this datasource already serves, and answers them (lot 2.5, PRD-1145). Publishing a many-to-one is not free: the agent marks it filterable as soon as any column of its target is, so the interface offers `assignee:name` the moment the relation exists. The traversal is therefore the bulk of this change, not the declarations. * eight many-to-one relations towards Admin, Team, TicketState and TicketType, whose targets all shipped with lot 1; * IntercomTeamMembership, synthesized from GET /teams: Intercom carries the membership on both sides and exposes no resource for the pair, while a many-to-many needs a collection to travel through. Read-only, Intercom writing none; * admin_names and team_names replace the arrays of ids on Team and Admin, read only when a projection asks for them; * one label per object on a ticket: state_category and state_external_label are dropped, both being a hop away on the state relation and neither having ever been filterable; * a projection through a relation reads its target once per page; a condition through one is resolved by the target, exactly, and becomes a condition on the foreign key. Over fifteen matching ids, or on a key the endpoint does not filter, it is refused by name rather than approximated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he relations (lot 4) (#386) * feat(intercom): contacts, companies and contact relations Publishes the two collections phase 2 is walked through, and turns the denormalized contact columns of lot 1 into a relation now that their target exists. Contacts read through the one endpoint of the API that sorts, so the measured table gained a sortable flag and the cursor tier a server-side order; the match-all predicate Tickets already sent is what routes a list view asking for an order through the search. Companies read through the third pagination tier, by offset, which is the one place the window a list view asks for maps onto what Intercom takes. In exchange it is looked up rather than searched, and anything past the two published lookups is refused by name. Contact and company custom attributes are typed from GET /data_attributes and carry api_writable for the lot that writes, though every column here is read-only. Resolving a relation condition is now bounded on the cursor tier: the target is read one record past what a group may hold, rather than walking a whole collection to refuse the fan-out afterwards. contact_email and contact_ids give way to contact_id and the contact relation, one readable label plus navigation, which is the rule lot 2.5 set for the ticket labels. No saved view can rest on them: both were already refused server-side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
41 new issues
|
| gem 'rspec', '~> 3.0' | ||
| gem 'simplecov', '~> 0.22', require: false | ||
| gem 'webmock', '~> 3.0' | ||
| end |
| end | ||
| end | ||
| end | ||
| end |
| # `list_key` is the key the endpoint puts its records under, and it is not | ||
| # `data` everywhere: `/tickets/search` answers under `tickets` (measured), so | ||
| # a caller names its own and `data` stays the fallback. | ||
| def list_page(path, per_page:, starting_after: nil, params: {}, list_key: 'data', boot: false) |
| # collections that read them never send one, and the parameter is here for | ||
| # the one that does. `{ field:, ascending: }`, translated to Intercom's own | ||
| # spelling on the way out. | ||
| def search_page(path, query:, per_page:, starting_after: nil, list_key: 'data', params: {}, sort: nil) |
| return [0, nil] if wait <= 0 | ||
| return [0, first_warning? ? wait : nil] if wait > @max_wait | ||
|
|
||
| [wait, nil] |
| @limit = limit if limit | ||
| return if remaining.nil? | ||
|
|
||
| @remaining = new_window || @remaining.nil? ? remaining : [remaining, @remaining].min |
| add(union, name, column, definition) | ||
| end | ||
|
|
||
| def add(union, name, column, definition) |
Review pass on PR #387. Correctness * a relation read slices the fan-out by what the target resolves in one read, and refuses past what it resolves at all, instead of resolving the first 25 rows of a page and leaving the rest nil; * the contacts of an account with a scope or a segment filtered alongside are refused with a message naming that condition, rather than with the table reason telling the operator to read the account's contacts, which is what they asked for; * the `id in [...]` route reports the order it cannot apply, refuses a count larger than a bulk read fetches, and deduplicates its ids; * the cursor walk logs the two defensive stops it took silently; * a date filter naming no day, a time of day or a month, is refused rather than completed from the clock of whichever host ran it; * the offset tier bounds a windowed read by a record budget instead of by the page cap meant for a read with no window of its own; * a projection reaching through two relations is refused by name, like the filter that reaches that deep. Consistency * the four enrichment hooks read the projection through `column_asked?`, so an all-columns read fills the columns it publishes; * `search_fields.yml` fails at load on a column filed as both filterable and refused, and on operators that publish nothing for its type, which is what its own docstring promised; * `active_support` and `active_support/time` are required rather than relied on through an incidental require of the toolkit; * a base_url carrying credentials is masked in every `inspect`, and a cleartext one is reported as carrying the bearer in clear; * `RecordsById` and `AttributeNaming` hold what the paginated tiers and the two introspectors were duplicating, messages included. Docs: nine collections rather than eleven, 19 of 85 table rows measured, free-text search on contacts too, what each relation really costs, and every limit above. 560 examples, 0 failures, 100% line coverage; rubocop clean on 912 files; toolkit, customizer and agent suites green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| !limit.nil? && count > limit | ||
| end | ||
|
|
||
| def read_targets(caller, collection, target, ids, wanted) |
| # caller: a page that looks like the whole answer and is not is the | ||
| # failure this datasource exists to avoid, so there is no route out of | ||
| # this walk that is short and quiet. | ||
| def cut_short?(page, seen_cursors, pages, records:, window:) |
Second review pass on PR #387. Four defects, each one a place where the package promised something it did not do. * a lookup naming no record is an empty page rather than a 500. Intercom answers `GET /companies?name=` with a 404 where a search endpoint would answer an empty list, and nothing read it: an operator typing an account name that matches nothing broke the page instead of seeing no row. Read the way a record read by its id already was, and only a 404 -- anything else still raises. The related list of an account Intercom no longer answers for reads the same way; * the pinned API version is checked at boot. `Client#me` reads the header Intercom echoes the served version in, and nothing called it: the check the configuration claimed ran was code that never ran, while a workspace serving another version answers payloads of another shape. Called from the datasource on the boot connection, degrading to a warning like the three attribute reads; * the table names every column of the collections it answers for. Seven columns had no row at all -- `state_id` among them, which the README ranks second on the list to probe -- so they fell back on the generic "takes no filter on it", the one refusal an operator cannot act on; and `state_external_label` outlived by two lots the column it refused. A spec now reads the schema against the table in both directions, which is what the reasons are worth; * the probe ships with the gem. `bin/` is excluded from `spec.files`, so the tool the README makes the first step against a customer's workspace reached nobody who had not cloned the repository. It moves to `exe/forest_admin_intercom_probe` -- and with it its guard, RubyGems loading an executable from a stub of its own, where `$PROGRAM_NAME == __FILE__` would have shipped a command that exits without doing anything. Consistency * `ContactIdentity` reads the Contacts path off the table rather than writing it a second time; * the account-contacts route reports an order it cannot apply with the route as the reason and not the column: this is the one collection Intercom does sort; * the attribute families of the table stop being data nothing reads. `Endpoint#attribute_refusal` is validated at load and read by the translator, so a scope filtering on a workspace attribute is refused with the R7 arbitration rather than with the message for a column nobody declared -- those columns being named by the workspace, no row can name them; * the README figures for the measured rows are derived from the file by a spec: 18 of 89, and they cannot drift from it again. 579 examples, 0 failures, 100% line coverage; rubocop clean on 912 files; toolkit, agent and customizer suites green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| end | ||
|
|
||
| print_operators(field, column, result[:operators]) | ||
| end |
| MAX_DEPTH = 2 | ||
| MAX_GROUP_SIZE = 15 | ||
|
|
||
| def self.call(condition_tree, endpoint:, collection:, timezone: nil, attribute_columns: []) |
| # which no row of the table can name: they are discovered at boot. They | ||
| # share one refusal, carried by the endpoint, and passing them here is | ||
| # what lets it be read instead of the generic message. | ||
| def initialize(endpoint:, collection:, timezone: nil, attribute_columns: []) |
POST /companies/list paginates by offset and advertises its next page by number, never by cursor. Routing it through the shared page builder made next_cursor refuse that shape, so both the list and the count answered 500. The offset tier now builds its page without reading pages.next, which it never used, and the company fixtures carry the advertisement the real endpoint sends on every page but the last. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…to two (#391) * perf(datasource): cut a page of Intercom tickets from seven requests to two Intercom joins nothing, so a ticket list projecting five relations was seven sequential round trips: the search itself, the contacts read twice over, and the four workspace lists re-read on every page. Three changes, none of which serves a record from a store: * the reference lists -- teammates, teams, ticket types, ticket states -- are held for reference_cache_ttl (60s, 0 to disable). They are what four of a ticket's five relations resolve through and they change a few times a year; what the window trades is bounded staleness, and nothing else is cached. * the reads issued while one page is built are deduplicated for the length of that call and no longer. The contacts of a page were asked for twice, once for the contact_name column and once for the contact relation, from two callers that cannot see each other. * connections are reused. Seven requests to one host meant seven TLS handshakes on Faraday's default adapter; the client now builds on faraday-net_http_persistent, and falls back with a warning where it cannot be loaded rather than failing to boot. Measured in ticket_list_budget_spec: 7 requests before, 5 on the first page after a boot, 2 on every page after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| end | ||
|
|
||
| entry | ||
| end |
| refuse_adapter!(adapter) unless name.respond_to?(:to_sym) | ||
| refuse_adapter!(adapter) unless options.nil? || namable_hash?(options) | ||
|
|
||
| options.nil? ? name.to_sym : [name.to_sym, options.to_h { |key, value| [key.to_sym, value] }] |
Pylon landed on main and registered itself in the three files a package registers in, which is where this branch registers Intercom. The conflict was those three and nothing else. Both entries kept in all of them: the release sed, the gem push and the git assets of .releaserc.js, the test matrix and the coverage file list of build.yml, and the three cop excludes of .rubocop.yml. Verified by parsing each file back rather than by reading the diff, since a lost entry here fails silently: the sed no-ops and the gem is never published, with nothing red in CI to say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the repository relicensing in #401: the gemspec still declared GPL-3.0 and the package shipped no LICENSE file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… causes Both re-raises in must_succeed chained the rescued error as cause, which full_message and error trackers print: a parsing error quotes the payload, and Faraday's error carries the request with its bearer token. The APIError keeps only the status and the body, so with no cause the request is no longer reachable from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elations aggregate read relation paths such as team:name on the flat records, so every row fell into one nil group: a team leaderboard showed one blank bar holding the total. The relations the aggregation names are now nested in first, as list does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…int filters Ticket#state, Ticket#previous_state and Contact#company were offered as filters the datasource then refused. Each many-to-one now sets is_filterable from the same check the runtime refusal uses, so the agent refuses the traversal before it reaches the datasource. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpanies by name Neither collection declared search, so the customizer emulated one as an equality on every String column: a ticket search came back as an empty page, a company search as an error about a filter nobody wrote. Both now declare search, tickets reach their existing refusal and the offset tier refuses one before reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arch takes A ticket returns its category as Customer, Back-office or Tracker, while /tickets/search takes request, task and tracker. A filter on the label the column shows was sent unchanged and matched nothing. Rows of search_fields.yml can now carry a values map from what the column shows to what the search takes; a value outside it is refused by name. The fixtures used the search vocabulary in responses, which is why no spec caught it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three tiers read a page limit of 0 like no limit at all, and walked the collection up to its caps. The toolkit's Page#apply and the cursor walker both read it as an empty window; a single page_limit helper now keeps nil and 0 apart, and no request is sent for an empty window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README still told an operator that company:name was refused with a message from this datasource; the relation is now published unfilterable and the agent refuses it first. It also says that a search on tickets or companies, global search included, comes back as a refusal. The company search refusal names id among the lookups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| collected = collect_pages(first_page: (offset / per_page) + 1, per_page: per_page, | ||
| wanted: limit && (skip + limit)) | ||
|
|
||
| limit ? (collected[skip, limit] || []) : collected.drop(skip) |
The overview still introduced lot 1, with filtering, contacts and companies announced for later; all three are served. It now names what is still to come. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…othing Any APIError was recorded as an operator refusal, so a 401, a 429 after the retries, a 5xx or a timeout wrote evidence that a valid filter is refused. Only a 400 or a 422, the answers Intercom gives a search it refuses, is a measurement now; anything else stops the run before it writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rinted redacted_url masked the credentials before the host but printed the query verbatim, so a gateway key passed as ?api_key=... reached Configuration#inspect, Client#inspect and the cleartext-url warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…allows plan_wait counted the request before reading exhaustion, so a remaining of 1 made the request it still allowed wait for the next window. Exhaustion is now read first and only a request let through is counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts first page A lookup answered over several pages was counted by the size of the first one. It is now counted by the total_count Intercom gave, and refused by name when a paginated answer carries none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ord budget The budget was checked after a page was appended, so a window could come back with up to a page more than MAX_COLLECTED_RECORDS. Intercom numbers its pages by per_page, so the last page cannot be asked smaller; what it brings past the budget is cut instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ther type Two ticket types declaring the same attribute name with different types shared one column, typed after the first: a Number column could show the other type's string. The introspector now records the ticket types that conflict and logs it, and a ticket of such a type reads the column as empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| # Intercom numbers its pages by `per_page`, so the last one read cannot | ||
| # be asked smaller: what it brings past the budget is cut here instead. | ||
| wanted ? records.first(MAX_COLLECTED_RECORDS) : records |
| return same_name(entry, definition, type_id) if entry.name == name | ||
|
|
||
| warn_collision(name, entry.name, column) | ||
| nil |
…on column The reference tier kept any sort clause naming a field it declares, a relation or a Json column included, and ordered by nil on every row without a word. Only sortable columns are kept now; the others are reported like a field the collection does not carry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds were named The window of an id in [...] read is cut on the ids, and the order a sort asked for is reported as not applicable on this route, the rows coming back in the order the ids were named. /contacts/search answers in its own order, so for contacts that was not true; the records are now put back in the order of the ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Faraday turns credentials in the url into a Basic Authorization header, which the Intercom bearer token then replaced: they were dropped on every request without a word, so a credentialed gateway could never authenticate. The url is refused at boot, pointing to HTTPS_PROXY, which Faraday sends as Proxy-Authorization. The refusals naming a base_url no longer print it unredacted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ConfigurationError raised for a base_url URI cannot parse chained the URI::InvalidURIError as its cause, whose message quotes the url unredacted, credentials included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| rescue URI::InvalidURIError | ||
| # Without the parser's error, whose message quotes the url unredacted. | ||
| raise ConfigurationError, | ||
| "ForestAdminDatasourceIntercom base_url is not a valid url: #{redacted_url.inspect}", cause: nil |
The gem subclasses and requires forest_admin_datasource_toolkit but only this repository's Gemfile named it, so installing the published gem on its own failed with a LoadError. Declared as Pylon and ActiveRecord do, the release keeping both versions in step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d is read by
The account's contacts relation filters IntercomContact on company_id
equal, but the column published no operator, so the customizer's
operators equivalence decorator crashed with a NoMethodError on both the
related list and its count. company_id now publishes equal, answered by
GET /companies/{id}/contacts, and a spec reads every to-many relation
through the customizer stack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hows A team's admin_ids can still name a teammate /admins no longer answers. The related list dropped that pair while its count kept it, so a team's teammates read N-1 rows under a counter of N. Memberships are now read against /admins, through the reference cache IntercomAdmin already uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ling the boot A null or non-object entry in /ticket_types, or in a type's attribute list, raised a NoMethodError outside the APIError rescue and stopped the agent from booting. Such an entry is now skipped and the other types keep their attribute columns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ooked up The refusal named the first leaf of the tree, so name = 'Acme' AND plan_name = 'Pro' blamed name, a column the message itself lists as filterable. It now names the first condition the collection does not publish, or the combination when each condition is filterable alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Counting a set of ids read every contact it named, up to three searches of a hundred full payloads. Each slice is now one search asked for a single record, and the count is the sum of their total_count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Companies, conversations and tickets read by id cost one request each, issued one after another: up to 100 for a related list of companies, 10 for a page of conversation timelines. Client#fetch_records now reads them concurrent_reads at a time, keeping the order of the ids, the 404 as nil, the read scope on the calling thread, and the failure of the earliest id once every worker is joined. concurrent_reads defaults to 1, the previous behaviour: every read in flight holds a connection of the pool, so widening it is left to the integrator, sized against pool_size and the server's thread count as the README explains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on a real workspace: Intercom refuses an IN naming more than 50 values with a 400 naming neither the field nor the condition. Contacts were read, counted and resolved 100 ids per search, so any page naming more than 50 contacts failed. They now go 50 at a time, and an in or not in filter of more than 50 values is refused before it is sent, with a message naming the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on a real workspace: past 50 values Intercom refuses the list's length, and past 15 it refuses the composite query each value counts as an element of. The limit holds per list, on IN and NIN alike, wherever the list stands: an IN of 15 next to another condition, or two lists of 10 and 6 in nested groups, are accepted. Contacts are now read, counted and resolved 15 ids per search, and a longer list is refused before it is sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| found[id] = record | ||
| scope[record_key(path, id, params)] = record if scope && record | ||
| end | ||
| ids.map { |id| found[id] } |
|
|
||
| def record_key(path, id, params) = ['record', path, id, params] | ||
|
|
||
| def read_record(path, id, params, boot: false) |
| end | ||
| end | ||
| end.each(&:join) | ||
| outcomes |
| open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new, | ||
| rate_limiter: RateLimiter.new, boot_open_timeout: 3, boot_timeout: 10, | ||
| boot_retry_policy: RetryPolicy.boot, | ||
| reference_cache_ttl: DEFAULT_REFERENCE_CACHE_TTL, adapter: nil, |
…op base_path Configuration#base_path was called by nothing. The adapter fallback could not run for the default adapter, a hard dependency of the gem, and for a configured adapter it hid the misconfiguration behind a slower default. An adapter the process cannot load is now refused at boot, like every other invalid option. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Collection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every comment block of eight lines or more is cut to three at most, keeping what the code cannot say: the measured Intercom behaviours and their figures, the workarounds, the concurrency constraints and the ticket references. Comment lines go from 2148 to 1270; no code line changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Integration branch of phase 1 of PRD-1111 — four lots already reviewed and merged here, now going to
maintogether:A new gem,
forest_admin_datasource_intercom, publishing nine read-only collections over the Intercom REST API. Everything outside those four lots — writes on contacts and companies (lot 4b, PRD-1152), actions, tags and segments (lot 5) — is out of scope and refused by name rather than half-served.The rule the whole package is built on
A page that looks filtered, sorted, counted or resolved without being so is the one answer this datasource must not give. Every limit of the API is either translated exactly or refused with a message naming what to change, before the request leaves the process:
pages.nextwhose cursor cannot be read is refused rather than taken for the last page;The single thing that cannot be refused is a sort on the endpoints that ignore one: Intercom accepts it and drops it silently (measured), so those columns are not sortable and a requested order is reported in the log.
POST /contacts/searchis the only endpoint that honours a sort, and the only collection with sortable columns — everywhere else, and on its own read-by-id route, an order asked for is reported rather than dropped.The collections
IntercomConversationGET /conversations,POST /conversations/searchIntercomTicketPOST /tickets/search,GET /tickets/{id}IntercomContactGET /contacts,POST /contacts/search,GET /companies/{id}/contactsIntercomCompanyPOST /companies/listIntercomAdmin,IntercomTeam,IntercomTicketType,IntercomTicketStateIntercomTeamMembershipGET /teamsNineteen relations are declared across the set — assignee, team, state, type, contact, company, owner, plus the two directions of the team membership — and a condition through a relation is resolved by the target itself and rewritten into a foreign-key condition, or refused by name where the endpoint filters no such key.
What a relation costs differs per target, and the README carries the table: the reference tier resolves a whole page for one request,
IntercomContactfor one request per hundred distinct contacts, andIntercomCompanyfor one request per distinct account — Intercom has no bulk read for a company. That last one is therefore the only relation with a ceiling: pastMAX_RELATION_READSdistinct accounts on a single read it is refused by name. Every page size a list view offers sits under it; an export, which batches a thousand rows, does not, and has to leave the column out.The operator table is data, and it says where each row comes from
The table is a committed YAML file, one row per column per endpoint, each carrying its provenance (
measured/spec), and everyfilter_operatorsin the package is derived from it — no collection writes one by hand, so a column cannot advertise a filter the translator would refuse. Every row is validated when the file loads, the script that rewrites it being the reason: an unknown operator or type, a column filed as both filterable and refused, and operators that publish nothing for the column's type all fail at boot rather than producing a schema nobody can explain. That is the mechanism, and it holds whatever the rows say.What the rows say today is mostly
spec: 19 of 85 are measured, and no endpoint has been probed end to end — all three carrymeasured_at: null.bin/probe_search_fieldsis what turns a candidate into a measurement, one search per (field, operator) cell against a real workspace, and running it is the first thing to do on the customer's. The README ranks the rows worth watching; the top of that list isadmin_assignee_id/team_assignee_id, typedstringhere where Intercom documentsInteger— they carry the filter an ops team reaches for first.Several measured points contradict Intercom's own documentation, and are recorded as such:
todayreturn nothing, since the two bounds the toolkit rewrites it into read as "from tomorrow" and "before today";x-ratelimit-limitis 1667 and describes the 10-second window, not the documented 10 000 a minute — the limiter paces on the instantaneous rate from the response headers;/tickets/searchrefusescompany_idwithinvalid_fieldalthough every ticket carries one;per_page=200is refused withinvalid_per_pageinstead of being clamped;statisticsblock, soclosed_atandlast_responderare derived from the parts, which arrive complete in the search response.Privacy
A conversation body is raw personal data. Nothing logs one: logs carry operations, counts, statuses and Intercom's request id. A response that fails to parse is named rather than quoted — a JSON parser opens its message with the characters it choked on, and on a 200 those are the payload.
display_as=plaintexton every conversation read, and the regional host is a first-class configuration parameter.Outside the package
Two changes in
forest_admin_datasource_toolkit, both inUtils::Collectionand both needed by the relation traversal:get_field_schemaraisesValidationErrorinstead ofForestExceptionon an unknown column, an unknown relation or a relation of the wrong type — each names a field the request asked for, which is a 400 the caller can read, not a 500 saying the agent broke. Nothing stops being caught:ValidationErrordescends fromForestException, so everyrescue ForestAdminDatasourceToolkit::Exceptions::ForestExceptionin the repo still catches these three. The observable change is the status —Http::ErrorTranslatormaps a toolkitValidationErroronto 400 — and the toolkit, customizer and agent suites are green on it;list_relationcompacts a related list: a through row whose target the foreign collection no longer answers yielded a nil where every consumer down to the JSON:API serializer reads a row by key.aggregate_relationcounting through those same nil targets is still open, and is mergeable onmainindependently.Verification
bundle exec rubocopclean across the repository, 910 files;version.rbformat, gemspec MFA opt-out,.rubocop.yml,.releaserc.js, and both lists ofbuild.yml.Known, and documented rather than discovered
IntercomContact.GET /companies/{id}/contactsreturns the account's contacts whole and narrows nothing, and/contacts/searchfilters no company field, so there is no request that takes both halves. The refusal names the condition it could not carry alongside the account. Resolving it properly means reading the account's contact ids first and handingid IN [...]plus the rest of the tree to/contacts/search, which that endpoint does answer — and which is not in this lot.company_idcolumn of a contact names the first of its accounts, and thecompanyrelation resolves that same one, whilecompany_id equals Xreturns every contact of X — so an account's related list can show a contact whosecompanypoints elsewhere. The column is the payload's reading, the filter the account endpoint's.The README carries the whole of it: collections, filtering per column with one reason per refusal, relations and what each costs, pagination tiers, rate limiting and configuration.
🤖 Generated with Claude Code
Note
Add read-only Intercom datasource (phase 1): collections, client, and query translation
forest_admin_datasource_intercompackage exposing read-only Intercom collections: Admin, Team, TeamMembership, TicketType, TicketState, Contact, Company, Conversation, and Ticket, registered in datasource.rbCursorCollection,OffsetCollection,FetchAllCollection) and a CursorWalker with caps of 50 pages and 7,500 records per walkValidationErrorinstead ofForestExceptionfor missing fields/relations, and drops nil through-relation targets in collection.rbForestExceptionfor invalid collection fields must now rescueValidationError; free-text search and unsupported aggregations on tickets/companies raiseUnsupportedOperatorErrorMacroscope summarized 5823c4b.