Repository navigation
feat(datasource intercom): read-only Intercom datasource, phase 1 - #387
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>
47 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>
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>
matthv
left a comment
There was a problem hiding this comment.
Reviewed the whole diff (tests: 696 examples green, 100% line / 89% branch coverage). The refusals the description promises hold up and are tested with their messages. The inline comments are the spots where a result can still look filtered, complete or resolved without being so, plus two comment/convention points.
A few facts in the PR description don't match the code:
- "19 of 85 are measured":
search_fields.ymlhas 89 rows (fields+refused) with 18 measured, which is what the README andsearch_fields_spec.rb:180say. bin/probe_search_fieldsdoesn't exist; the script isexe/forest_admin_intercom_probe.- "IntercomContact for one request per hundred distinct contacts": it's per 15 (
Contact::IDS_PER_READ = FilterValue::MAX_LIST_SIZE), as the README says. - "the script that rewrites it": the probe writes evidence and says to merge it by hand (
exe/forest_admin_intercom_probe:24-27).search_fields.rb:3andsearch_fields.yml:16-17repeat the same claim. - The lot mapping (writes/actions in lot 5) disagrees with the README's roadmap (lot 3 for writes and actions, lot 5 for notes/tags/segments, lot 6 for group-by).
Addresses the review of #387: - refuse a date bound inside a day, except an upper bound at the present on a column that holds past instants (past and the *_to_date family) - refuse a list by more ids than one read fetches, as the count already did - refuse an empty body instead of reading it as an empty list - cache no reference list cut at the page cap - map only transport failures to APIError, so a bug is not logged as an outage - degrade the boot introspection on a 403 only, and log the error message - leave admin_names / team_names nil when the names cannot be read - leave a ticket timeline nil when the payload carries no parts, and warn when Intercom truncated it at 500 parts - answer an Or [] tree on companies with no rows and a count of 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions The client refuses an adapter it cannot load rather than falling back, the probe writes evidence merged by hand rather than rewriting the table, and actions will ship as plugins, never declared by a collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review, matthv. Every inline point is answered in its own thread: Description: corrected on all five points. It now says 18 of 89 measured rows, qlty: these findings are advisory ( |
| end | ||
| end | ||
|
|
||
| [records, true] |
| # mapping it would have the callers that degrade log it as a transient Intercom outage. | ||
| raise unless RetryPolicy.connection_failure?(e) | ||
|
|
||
| raise APIError, "Intercom API call failed: #{operation}: #{e.class}: #{e.message}", cause: nil |
|
|
||
| refuse_condition!(filter.condition_tree) unless filter&.condition_tree.nil? | ||
|
|
||
| listed_records(filter) |
|
|
||
| refuse_condition!(filter.condition_tree) unless filter&.condition_tree.nil? | ||
|
|
||
| exact_count(read_offset_page(page: 1, per_page: 1)) |
| @now = now | ||
| end | ||
|
|
||
| def call(seconds, spelling, leaf, field) |
Forest Validator · Verify fixes: ✅ 1 closed
1 earlier step
|
matthv
left a comment
There was a problem hiding this comment.
Changes requested after /verify-fixes run 387-20261104095319-matthv-verify: 1 reopened, 0 regressions.
A bound on the caller's midnight in a negative-offset zone has the same local and UTC date, so the widening went unreported. Warn whenever a bound is not on a UTC midnight, except the upper bound at the present. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| @now = now | ||
| end | ||
|
|
||
| def call(seconds, spelling, leaf, field) |
|
|
||
| return day - UTC_DAY if spelling == '>' | ||
|
|
||
| seconds == day ? seconds : day + UTC_DAY |
A company_id equality sent with a search skipped the account route and reached the translator, which blamed company_id for a filter the operator never wrote. It now gets the account refusal, naming the search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
list_relation dropped through rows whose target was nil while aggregate_relation still counted them, for every datasource. The toolkit is back to main, and the Intercom client now reads a reference list once per page, so TeamMembership and its admin relation see the same /admins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The category is read as its label (Customer, Back-office, Tracker); only the search takes request, task and tracker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| causes = [] | ||
| causes << "condition on #{others.uniq.join(", ")}" unless others.empty? | ||
| causes << "search #{filter.search.to_s.strip.inspect}" unless blank_search?(filter) | ||
| causes.empty? ? 'condition filtered alongside it' : causes.join(' and the ') |
| @now = now | ||
| end | ||
|
|
||
| def call(seconds, spelling, leaf, field) |
|
|
||
| return day - UTC_DAY if spelling == '>' | ||
|
|
||
| seconds == day ? seconds : day + UTC_DAY |
|
@matthv the reopened finding (Sub-day date filters return a superset) is addressed in e425693: Three more commits came out of a self-review since your pass:
Could you take another look? |
matthv
left a comment
There was a problem hiding this comment.
Approved after /verify-fixes run 387-20261104131409-matthv-verify: all 11 of my review threads are closed, none reopened.
|
🎉 This PR is included in version 1.49.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |

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 tickets and conversations (lot 3), writes on contacts and companies (lot 4b, PRD-1152), notes, tags and segments (lot 5), the bounded group-by (lot 6), and actions, which will ship as plugins the agent opts into rather than from the collections — 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;future,after_x_hours_ago,before_x_hours_ago, a time of day — is refused, since Intercom would answer the whole UTC day. The one exception is an upper bound at the present (past, the*_to_datefamily) on a column that holds past instants, where the wider day adds no row;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 fifteen 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, since the file is edited by hand from what the probe measures: 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: 18 of 89 are measured, and no endpoint has been probed end to end — all three carrymeasured_at: null.exe/forest_admin_intercom_probeis 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, 997 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 raiseUnsupportedOperatorErrorChanges since #387 opened
ForestAdminDatasourceIntercom::Query::DayBoundsclass and passed the leaf and field throughForestAdminDatasourceIntercom::Query::FilterValue. [0f2525d]ContactandRecordsByIdcollections, and added an empty-Orshort-circuit inOffsetCollection. [0f2525d]ForestAdminDatasourceIntercom::ClientandForestAdminDatasourceIntercom::Cacheto avoid caching partial lists and to change error handling. [0f2525d]DataAttributesIntrospectorandTicketAttributesIntrospectorto HTTP 403. [0f2525d]nil) when lookups fail in theAdminandTeamcollections. [0f2525d]ForestAdminDatasourceIntercom::Collections::Ticket.embed_timelineto keeptimelinenil without parts and to warn on truncation. [0f2525d]ForestAdminDatasourceIntercom::Query::DayBoundsemits the UTC-day truncation warning, and removed the local-date comparison. [e425693]ForestAdminDatasourceIntercom::Query::FilterValuespec for a caller west of UTC. [e425693]ForestAdminDatasourceIntercom::Client.fetch_allresults to the read scope [a1dbbdb]ForestAdminDatasourceToolkit::Utils::Collection.list_relationto keep nil entries for through relations [a1dbbdb]ForestAdminDatasourceIntercom::Collections::Contact[a1dbbdb]categorycolumn inTicketandTicketTypeschemas [a1dbbdb]Macroscope summarized 5823c4b.