feat(datasource-pylon): custom fields introspection (EXT-10) - #359
Merged
christophebrun-forest merged 10 commits intoAug 19, 2026
Conversation
1 new issue
|
christophebrun-forest
force-pushed
the
ext-9-messages
branch
from
August 18, 2026 14:48
c45d504 to
f940b27
Compare
GET /custom-fields takes a mandatory object_type, so the definitions of each collection are read by their own call. The walk carries the parameter on every page: dropped on the second request, it would answer for another object type or with a 400. Degrades to an empty list, like the message thread: this is read while the agent boots, and a token missing the permission has to cost the operator the custom columns rather than the whole datasource. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Maps the custom fields an organization defined onto Forest columns, as
the { column_name:, schema: } entries add_custom_fields registers. The
column is named after the Pylon slug verbatim: it is both the key a read
payload indexes the value by and the field a search filter sends, so
renaming it would only add a mapping to keep in step.
A select advertises the slugs of its options rather than their labels --
that is what Pylon reads back and what it matches a filter against -- and
falls back to String once every option is gone, as Forest refuses an Enum
carrying no value. A type this datasource cannot map is skipped with a
warning instead of guessed at: a column whose Forest type does not match
what Pylon holds filters and displays wrong.
The operators come from CUSTOM_FIELD_OPS, so a collection's clamp has
nothing to drop. A Number gets no comparison: Pylon spells the bare
comparisons time_is_after / time_is_before, which would send a numeric
range as a time filter. A multiselect gets none at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The datasource introspects at boot, one call per object type: an issue, an account and a contact carry custom fields, a user and a team carry none, so nothing is asked for those two. Each entry stays on the collection its object type registers. There is no datasource-wide mapping like the Zendesk one: a Pylon custom field is filtered through the very slug it is read by, which the collection's api_filters already carries, so two datasources in the same agent cannot end up advertising each other's columns. The spec asserting no API call at registration is replaced by the property that matters now: definitions that cannot be read leave the agent booting on the native schema. The collection specs building a datasource declare what the introspection answers through stub_custom_fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The read pipeline and the filter table were already covered per collection, on hand-written column schemas. What was not covered is the seam: a definition read from /custom-fields becoming the column its value is read through and its filter is sent by. Slug alone holds the three together, so a rename would pass every spec taken in isolation. Covers a select, read back and filtered by the option slug, and a multiselect, read out of `values` and left unfilterable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| cursor = page.next_cursor | ||
| end | ||
|
|
||
| records |
BASE_OPS derives from Maps::PRESENCE, which EXT-8 extended with MISSING after this branch was written: Pylon spells absence through is_unset alone, and a field advertising PRESENT and BLANK without MISSING would refuse the very filter its endpoint can answer. The code needed nothing -- it reads the table rather than restating it, and CUSTOM_FIELD_OPS merges PRESENCE too, so the collection clamp has nothing to drop. Only these four expectations still named the old two-operator presence family. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
christophebrun-forest
force-pushed
the
ext-10-custom-fields
branch
from
August 18, 2026 15:22
8a79680 to
3571e0f
Compare
A custom field column left the ColumnSchema default is_groupable: true, and the capabilities route turns supportGroups on as soon as one field carries it: a single custom field on Issue, Account or Contact made the UI offer a group-by that aggregate raises on. Every native column declares it false. A Date or Dateonly custom field also advertised in / not_in, which Rules grants no date column, so ConditionTreeValidator refused the filter before the translator saw it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The API reference documents what a custom field is, never the form its
value is read back in, so a Number answering "42" is not ruled out.
The column now holds the form the agent gives a filter value of the
same type -- ConditionTreeParser casts a Number with to_f and a
Boolean into a real boolean -- so the two stay comparable whichever
one Pylon answered with. A date stays the ISO string it is: the filter
carries one too, and comparing two of those is the ordering itself.
One path turns the form into a result. An id filter is answered by
GET /issues/{id} and the rest is applied in memory, so a number left
as "42" was compared with the 42.0 the agent casts the filter to, and
the row was dropped without a word.
A number that cannot be read reads as absent rather than as zero, and
an empty boolean stays absent as well: false is an answer, and Pylon
gave none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two shapes reach the wire from a custom field alone: no native column is filterable as a Number, and none is typed Dateonly. The agent casts every Number column with to_f, so an integer custom field was filtered with 42.0, a form none of its values carry. A float with nothing after the point now travels as the integer it is, and a decimal keeps its own. time_is_after gets a timestamp from every native date column, where a Dateonly custom field sent it the bare date the frontend picks: it now gets the bound a Ruby Date already got, midnight in the timezone of the caller. Read off the emitted operator rather than off the shape of the string, so a text field holding what looks like a date keeps its value; a string no date can be read from is left to Pylon, which names what it refuses better than a guess here would. Neither shape is documented and neither could be tried against an org, so both are the canonical form rather than a verified one. What is left is a filter Pylon accepts without matching, which EXT-11 has to probe before it trusts the write half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| return nil if value.nil? | ||
|
|
||
| case column_type | ||
| when 'Number' then Float(value, exception: false) |
There was a problem hiding this comment.
🟡 Medium collections/record_serialization.rb:42
coerce_custom_field rounds valid large Pylon Number values by converting them to Float; for example, "9007199254740993" is exposed as 9007199254740992.0. This corrupts record reads and can make equality filters compare against the wrong number. Preserve integer and decimal precision instead of coercing every Number through Float.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/collections/record_serialization.rb around line 42:
`coerce_custom_field` rounds valid large Pylon Number values by converting them to `Float`; for example, `"9007199254740993"` is exposed as `9007199254740992.0`. This corrupts record reads and can make equality filters compare against the wrong number. Preserve integer and decimal precision instead of coercing every Number through `Float`.
Dropping MEMBERSHIP from TIME_OPS was documented as keeping `in` off a date column. It does not: the equivalence decorator republishes `in` from the `equal` the set declares, the IN transform depending on EQUAL for every column type, and a DATEONLY also gets the hours-ago pair republished from the comparisons. The validator refuses all three. No operator set declared here avoids it, and the same operators are already published by the ActiveRecord datasource, so this is the toolkit's to settle -- tracked as PRD-989. Comment only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`coerce_custom_field` widened every Number read with `Float`, so an integer custom field answered `42` was held as `42.0` and displayed a decimal it does not carry -- while `FilterValue#format_float` narrows the very same value on the way out, the two halves disagreeing on what an integer looks like. Nothing needed the widening: `ConditionTreeLeaf#match` compares with `==` and `Array#include?`, both of which hold across Integer and Float, so a value already numeric is handed back untouched and only a string is read -- to the tightest form, which is the form the wire sends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
christophebrun-forest
merged commit Aug 19, 2026
9f80673
into
feat/datasource-pylon
52 checks passed
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.
Closes EXT-10 — Story 6 of EXT-4.
What
The custom fields an organization defines in Pylon were invisible: the collections already knew how to hold them —
add_custom_fieldsskips collisions and clamps operators,add_custom_field_valuesreads{slug, value}/{values},api_filtersmerges the slugs — but nothing ever read the definitions, so every collection booted with an empty list. This adds the missing source.client.rbfetch_custom_fields(object_type);collect_pagesnow carries query paramsschema/custom_fields_introspector.rb{column_name:, schema:}entriesdatasource.rbspec/spec_helper.rbstub_custom_fields, for the specs that build a datasourcecollections/record_serialization.rbquery/filter_value.rbquery/condition_tree_translator.rbSix decisions worth reviewing, and a correction to the fourth
One call per collection, not one call in total.
object_typeis mandatory onGET /custom-fields, so boot spends three requests:issue,account,contact. Users and teams get none — Pylon carries no custom field on either, so an absent call rather than one answering empty. The parameter also rides along on every page of the cursor walk: dropped on the second request, it would answer for another object type.A select advertises the slugs of its options, not their labels. Pylon reads a select back as the option slug and matches a filter against the slug ("the value must be the select option slug"), so exposing labels would need a mapping between what the operator sees and what travels — one more thing to keep in step, for a UI gain. Once every option is removed the column falls back to
String, since Forest refuses anEnumcarrying no value.A Number gets no comparison operator.
CUSTOM_FIELD_OPSspells the bare comparisonstime_is_after/time_is_before, which is all Pylon documents on a custom field, so declaringgreater_thanon a numeric column would send a numeric range as a time filter. Numbers get equality and presence; dates get the comparisons; a multiselect gets nothing, its membership operators not being part of what a custom field accepts. Everything declared is drawn fromCUSTOM_FIELD_OPS, so the per-collection clamp has nothing to drop and stays silent.A date also gives up
in/not_in, which the rest ofCUSTOM_FIELD_OPScarries —Rules.get_allowed_operators_for_column_typegrants aDateor aDateonlycolumn no array operator, the hazardoperator_maps.rbalready documents forMEMBERSHIPon aJsoncolumn. Native date columns declare the comparisons alone for the same reason.Dropping them does not keep the filter out of the UI, though, and nothing in this datasource can.
OperatorsEquivalenceCollectionDecorator#refine_schemarepublishesinfrom theequalthe set declares — the IN fallback transform isdepends_on: [EQUAL]with nofor_types, so it fires on every column type — and aDateonlyalso getsafter_x_hours_ago/before_x_hours_agorepublished from the comparisons,Times.comparederiving themfor_types: %w[Date Dateonly]whereRules::DATEONLYrefuses them.ConditionTreeValidatorthen rejects all three, so the operator is offered a date filter the agent answers with a 400.Declaring
equalis what triggers the first one, and giving it up to dodge a toolkit defect would cost an operator Pylon really accepts on a custom field. It would also protect nothing:QueryStringParservalidates against the decorated collection beforerefine_filterrewrites, so the refusal does not depend on what this table declares. The same four operators are already published by the ActiveRecord datasource (parser/column.rb:108declaresequality + orderablesonDateandDateonly), so this is a live toolkit contradiction rather than anything introduced here — tracked as PRD-989, to be fixed inRulesoutside this story. The set below is what Pylon accepts, which is the only question it can answer.No custom field is groupable.
ColumnSchemadefaultsis_groupabletotrue, and/_internal/capabilitiesturns a collection'ssupportGroupson as soon as a single field carries it, so one custom field left at the default is the whole collection offering a group-by thatBaseCollection#aggregateraises on. Every native column declares itfalsealready; a custom field now does too, and Pylon exposing no aggregate endpoint is the one reason behind both.A type this datasource cannot map is skipped with a warning rather than guessed at: a column whose Forest type does not match what Pylon holds filters and displays wrong, which is worse for the operator than a column that is not there.
Two shapes no document settles
The custom fields are the first columns of this datasource typed
Number,BooleanorDateonlyand filterable — no native column is both,API_FILTERScarrying none of them — so two value shapes reach the wire here for the first time. The API reference documents what a custom field is and never the form its value is read back in, and neither shape could be tried against a real organization: both are the canonical form rather than a verified one, and the specs pin them so a change is a decision rather than a drift.A value is read as its column's type, not as a widened one. A
numberanswering"42"is not ruled out by anything documented, so a value that is not already numeric is converted. One path turns that form into a result: an id filter is answered byGET /issues/{id}and the rest is applied in memory, so a number left as"42"was compared with the42.0ConditionTreeParser.cast_to_typecasts the filter to, and the row was dropped without a word.A value that is already numeric is handed back untouched.
ConditionTreeLeaf#matchcompares with==andArray#include?, both of which hold across Integer and Float, so nothing needed widening — and widening it made an integer field display the12.0it does not hold, whileformat_floatbelow narrows the very same value on the way out. A string is read to the tightest form for that reason: the two halves now agree on what an integer looks like. A Boolean becomes a real boolean, a date stays the ISO string it is — the filter carries one too, and comparing two of those is the ordering itself. A number that cannot be read reads as absent rather than as zero, and an empty boolean stays absent as well:falseis an answer, and Pylon gave none.A number and a bare date are normalized on the way out. The agent casts every Number column with
to_f, so an integer custom field was filtered with42.0, a form none of its values carry: a float with nothing after the point now travels as the integer it is, a decimal keeps its own. Andtime_is_aftergets a timestamp from every native date column, where aDateonlycustom field sent it the bare date the frontend picks — it now gets the bound a RubyDatealready got, midnight in the timezone of the caller. That one is read off the emitted operator rather than off the shape of the string, so a text field holding what looks like a date keeps its value; a string no date can be read from is left to Pylon, which names what it refuses better than a guess here would.What is left is the one failure these cannot rule out: a filter Pylon accepts without matching. It takes an organization carrying a field of each type to see, which EXT-11 has to probe before it trusts the write half.
Two deviations from the plan
No write half. The story scope mentions a payload builder, but no write endpoint exists yet — story 7 (EXT-11) owns
create/update. Custom-field columns are declaredis_read_only: truelike every native column, and EXT-11 flips both at once, with a real endpoint to test against. Pylon's ownis_read_onlyflag starts being honoured there too. The scope item is to be moved to EXT-11.No
pylon_slugkey on the entries. The plan had one, mirroring Zendesk'szendesk_id/zendesk_key. It would have duplicatedcolumn_name: there is no rename here, since the slug is both the key a read payload indexes the value by and thefielda filter sends. Entries are{column_name:, schema:}.One behaviour change to existing specs
The datasource now performs HTTP while registering its collections, so
does not call the API while registering collectionsno longer holds. It is replaced by the property that matters: definitions that cannot be read leave the agent booting on its native schema —fetch_custom_fieldsdegrades to an empty list, like the message thread does, so a token missing the permission costs the operator the custom columns and not the datasource. The seven collection specs building a datasource declare what the introspection answers throughstub_custom_fields; the helper takes the base url from the configuration default rather than from the datasource, since reading it there would boot the very introspection it stubs.Verification
557 examples, 0 failures— coverage 1083/1083 lines (100%, threshold 90), branch 96.1%. RuboCop clean over the 51 files of the package.Specs cover: the type table, an unmappable type and a slug-less definition being skipped, option slugs and the empty-options fallback, the operator set per type — including the exact set a date gets, asserted whole so the membership operators cannot creep back in — read-only / unsortable / ungroupable schemas;
object_typesent and kept across pages, degradation on 403 and 500; three calls at boot and none for users or teams, each definition landing on the collection its object type registers, a slug colliding with a native column skipped end to end, two datasources not sharing columns; and the round trip that ties the three together — a select read back and filtered by its slug, a multiselect read out ofvaluesand left unfilterable.And, for the two shapes above: every form a number and a boolean can come back in, including the ones that read as absent and the integer forms that must not widen; the integer-valued float travelling as an integer while a decimal keeps its own; the bare date read as midnight in the caller's timezone, the date-shaped text left alone on an operator that is not a time comparison, and the string no date can be read from left to Pylon. Each was checked to fail without its fix — the two that hold either way are named as the non-regression they are.
🤖 Generated with Claude Code
Note
Add custom fields introspection to Pylon datasource collections
CustomFieldsIntrospectorto fetch custom-field definitions from Pylon'sGET /custom-fieldsendpoint and expose them as typed columns on Issue, Account, and Contact collections.time_is_after,time_is_before) are converted to midnight UTC ISO8601 timestamps; integer-valued floats are sent as integers.Client#collect_pagesis fixed to preserve query params (e.g.object_type) across all paginated requests.GET /custom-fieldsper supported object type on boot; failures are logged and degrade to an empty custom-field set rather than raising.Macroscope summarized 2e6a53f.