Findings — read: getting rows back
How the API behaves in this part of a session. Each rule is the entry's own Teaches, copied whole.
003_query
A dotted ?fields path comes back flat under literal key "sg_task.Task.content" in attributes; an entity field is returned under relationships as {data, links}. Never read a row from attributes alone.
Two query styles both work: flat
filter[project.Project.id]=Non a GET, andfilters: [[field, op, value]]in a POST_searchbody._searchrefusesapplication/jsonand needs the vendor Content-Type (probe 004).sort=-idwithpage[size]/page[number]behaves as documented and returns a different slice; no total count comes back with it (probe 006).An unknown name in
?fieldsreturns 200 with the key absent, so a typo reads as "no data", not as an error; the same name infilter[]400s (probe 004).Asking for an entity field by bare name already yields
namealongsideidandtype, so resolving a link for display costs no second call.The middle segment of a dotted path is checked against the field's
valid_typesin?fieldsand against the schema in a filter, so a wrong type is a 200 with the key absent and 400s only as a filter (probe 059).
corpus/findings/003_query.md
005_link_usage
On the sample project every Version links through entity (99% Shot, 1% Asset) and only 1% through sg_task, so measure link usage per site rather than hardcoding Task-linking.
On the probed site
entityis the load-bearing link andsg_taskis near-unused: a client that assumes Version to Task finds nothing 99% of the time. Measure link usage per site before coding against it.entityis polymorphic. Readrelationships.entity.data.typeper row; do not assume Shot even at 99%.A multi-entity field can be uniformly empty (
playlists0/100), so absence of data is not absence of the field.page[size]=500returned 100 rows because the project holds exactly 100 Versions, not because of a cap: probe 016 shows 150 returns 150.
corpus/findings/005_link_usage.md
006_pagination
links.next is emitted on every page forever, including zero-row ones, so stop paging when data is empty and never on a missing next.
links.nextis not a terminator: it is present on empty pages too, so "follow next until absent" is an infinite loop. Stop on an emptydataarray.Only the stop signal is wrong. Explicit
page[number]walks the set, and the short final page returns the remainder.No total is in a paged read. A GET returns
['data', 'links']and nometakey, andlinksholds onlyselfandnext, neverlast. POST_searchwithoptions.return_paging_inforeturns the same two keys.Five option spellings (
options[return_paging_info],options[include_paging_info],page[totals],include_count,meta[total]) are accepted at 200 and change nothing;page[size]=0is 400size must be greater than 0.For "n of N", count with one POST
/entity/<type>/_summarizeand{"field": "id", "type": "count"}, which returned{"summaries": {"id": 100}}against the 100 rows the walk above found (probe 020).
corpus/findings/006_pagination.md
007_fill_rates
On the sample project 30 of 71 Version fields are populated. Rank by fill rate, but drop checkbox, summary and computed fields first: False and 0 are not null and read as 100% filled.
A boolean reads as 100% filled because False is not null, and so does a summary count of 0 and a computed list. On the probed site 6 of the 17 fields at 100/100 hold one constant across all 100 rows, so a fill-rate ranking that includes them is wrong at the top.
Exclude checkbox, summary and computed
data_types from fill ranking, or confirm a candidate with_summarizegrouping, which returns a single group for exactly these fields.probe 020reaches the same trap from the_summarizeside, where a checkbox cannot be filteredis_not None.On the probed site fill rate is bimodal: 100% or 1%, nothing in between. The 1% band is twelve media fields on the one uploaded Version, so a threshold anywhere between 2% and 98% separates structure from anecdote.
The roster is not stable: the schema grew from 61 to 71 fields since this probe first ran, and all 10 additions read 0/100. Re-read
/schema/Version/fieldsper run; one paged fetch of 100 rows then measures every field in a single call.
corpus/findings/007_fill_rates.md
018_project_listing
sg_status is not a liveness filter and is null on 15 of 22 projects; is_template, is_demo and archived are the discriminators, so pick the ones your list wants - is_demo hides the demo show.
Trap.
sg_status is Activeis not a liveness filter. It is not set automatically, and on the probed site 7 of 22 projects have a value against 15 null, 8 of the nulls non-template working shows, so the filter returns 5 and hides the rest. What sets it on those 7 is unmeasured: this probe reads the tally, not the history.sg_statusis a list field with valid values Bidding/Active/Lost/Hold and nodisplay_values, so even where it is set there is no label to put in front of a user (probe 009).The checkboxes are the discriminators. On the probed site:
checkbox True on is_template7, the stock templates is_demo1, the shipped demo show archived0 of 22 All three False leaves 14 of 22. The archived clause is proven harmless but not proven to exclude anything; keep it, it costs nothing.
corpus/findings/018_project_listing.md
021_media_resolution
PublishedFile.path is returned with the LocalStorage join already done, so a client never reads LocalStorage or reassembles a root, but a platform whose storage root is unset reads null.
| tier | what resolves | second call |
|---|---|---|
1. Version.published_files then PublishedFile.path |
mac, windows and linux absolute paths at once, the LocalStorage join already done | yes, one _search |
2. sg_path_to_movie, sg_path_to_frames |
one absolute path, no platform variants | no |
3. image, sg_uploaded_movie |
a presigned S3 URL | no |
Tier 1 is untested here for two reasons, and only one of them is about the API. On the probed site, Image, Rendered Image, Texture and USD PublishedFiles have no
pathat all, andVersion.published_filesis filled on 2 of 53 Versions: that is Flow PT data. The Movie paths that do exist point at files the operator has since deleted from disk: that is not.Read this as "this site has no publish history", never as "Flow PT paths are unreliable".
Tier 2 holds one absolute path, so a value cannot resolve on two platforms, unlike
PublishedFile.path, which returns one per platform the LocalStorage row defines a root for. On the probed site that ismac_pathonly, solocal_path_windowsandlocal_path_linuxread null (recipes/004).On the probed site
sg_path_to_framesis 0 of 53, leaving the sequence form untested: it is free text taking printf padding and the Shake#/@forms, so never assume%04d.Tier 3 needs no second call:
imageis a presigned S3 URL as a plain string, andsg_uploaded_movieis a dict with the same URL underurl. It does not always resolve. On the probed siteimageis filled on 33 of 53 Versions in the sample project and 98 of 1057 site-wide, so test the field rather than assuming a fallback.Trap.
sg_uploaded_moviecannot be filtered or summarizedis_not None: 400API summarize() Version.sg_uploaded_movie's 'url' data type cannot be used in a filter.Same shape of trap as a checkbox (probe 020).Offer the operator whichever tiers a given Version can deliver rather than picking one for them.
corpus/findings/021_media_resolution.md
023_pages
A page's layout is the PageSetting row whose user is null; settings_json reads back as decoded JSON and body/list_content settings.columns is the column list. Every filter on it is ignored.
Three types answer the question, and a page's configuration is split across two of them.
| type | what it holds | how a client reaches it |
|---|---|---|
Page |
the page itself: name, page_type, entity_type, project, ui_category, system_owned |
GET /entity/pages?fields=... |
PageSetting |
the layout, in settings_json |
_search on page is {"type":"Page","id":N} |
PageHit |
one row per view, page and user only |
GET /entity/page_hits |
There is no DisplayColumn type, and Page has no field naming a column.
A site-level page is a
Pagewhoseprojectis null, and nothing else about it differs. Both kinds read from the same endpoint, both holdPageSettingrows of the same shape, and both take the same filters.The two are told apart by
projectalone, sofilter[project.Project.id]=Nreturns a project's pages and[["project","is",null]]returns the site-level ones. Send the string"null"to the flat filter and it 400s withgot String: "null"; the operator wants a real null.PageSetting.settings_jsonistextin the schema and decoded JSON in the response, so parse nothing.Two shapes come back under one field: an object is the page's shared layout and its
useris null, an array is one user's override and itsuseris set. Read[["page","is",{...}],["user","is",null]]to get the shared one and ignore the rest, or a personal column order will read as the page's.The shared layout is a widget tree of
{type, settings, children}.children.body.settingsholdsentity_type,mode,sorts,groupingandfilters;children.body.children.list_content.settingsholdscolumnsin display order, pluscolumn_widthsandcolumn_display_names.The override array is
[{spec_path, settings}], wherespec_pathis that same tree path with|between the segments, sobody|list_contentpatches the grid.columnsare schema field names, usable in?fieldsas they stand, and a dotted path such ascreated_by.HumanUser.emailappears among them.Some are stale or web-only: on the probed site, 5 of the 21 list pages in one project named a column absent from that type's
/schema/<Type>/fields.?fieldsanswers 200 and drops a name the type does not have, so a stale column costs a missing key rather than an error. Check the list against/schema/<Type>/fieldsto know which columns you lost.filtersis the web condition tree (path,relation,values,logical_operator), not the_searcharray of probe 017. The names line up, so a converter is a walk overconditions, but the tree also holdsactive,filter_nameandfilter_idfor a saved filter, and atop_level_project_filtercondition that duplicates the project scope.Every filter on
settings_jsonis accepted and ignored. On the probed site all 30145 rows come back forcontains "ZZZNOPE", foris nulland foris_not nullalike, while[["page","is",null]]on the same type returns 26372, so the endpoint filters fine and the field does not. Never search layouts server-side; page the rows and inspect them yourself._summarizedisagrees with the listing onPage. On the probed site an unfilteredrecord_countreturned 2576 against 1217 rows actually paged, while the same count filtered by project agreed exactly, summing to 1217 over 22 projects plus 125 project-null. Count pages by summing per-project and per-null, never with one unfiltered call.PageandPageSettingfilter and sort like any other type: the bogus-operator 400 enumerates the normal relations for each field's data type, andsorton a name that is not a field is accepted at 200 and ignored.
corpus/findings/023_pages.md
026_result_order
Rows come back id ascending unless you sort; ["id", "in", [...]] discards the order of the list, and an unsortable or unknown sort field is a silent 200 no-op where the same name in a filter 400s.
["id", "in", [...]]returns id ascending, never the order of the list. A caller that must preserve a selection re-sorts against what it sent:pos = {i: n for n, i in enumerate(ids)}, thenrows.sort(key=lambda r: pos[r["id"]]). The bug hides because an already-ascending list comes back looking honoured.With no
sort, the order is id ascending, and it held over five identical calls. Paging is stable: five walks of 100 rows atpage[size]=10, including the unsorted one and one keyed on a low-cardinality status, each returned every row once, in the order of the same query read unpaged.Sorts fail silently where filters fail loudly (probe 017). A
summaryfield, aurlfield and a name that does not exist all return 200 with the rows in default order; the same three names in a filter 400 and name the reason.Only sort syntax errors: an empty value, a space, a leading
+. There is no way to detect a dropped sort from the response, so verify a sort field against/schema/<Type>/fieldsbefore relying on it.Multi-key sort is comma separated with
-per key, and id ascending is the implicit tiebreak:sg_status_list,idreturned the identical page tosg_status_listwhilesg_status_list,-iddid not.A dotted path sorts (
entity.Shot.codehere, and site-wideproject.Project.namereverses under-). POST_searchtakes"sort"only as the same string; the array-of-objects spelling 400ssort array is not valid.
corpus/findings/026_result_order.md
048_one_record_beyond_crud
POST on one record is revive, not update: ?revive=1 is required and the body is ignored. /<field> reads image and attachment fields only, and relationships/<field> is the same data, unpaged.
| call | what it is | what a caller assumes |
|---|---|---|
POST /entity/<type>/<id> |
revive a retired row | an update, or a create with an id |
GET /entity/<type>/<id>/<field> |
one image or attachment field, with a download | any field, read cheaply |
GET .../relationships/<related_field> |
the link list, unwrapped | a paged sub-collection |
PUT /entity/projects/<id>/_update_last_accessed |
stamps a user's project history | something readable back |
GET /exports/page/<page_id>.<format> |
a saved page view as CSV | any page, any format |
POSTon a single record isDELETErun backwards.?revive=1is required,revivemust be truthy (0andfalseare refused withrevive must be true), and a JSON body is accepted and discarded, so a client reaching for it as aPUTalias gets a 400 telling it about a parameter it never sent. The row comes back with the field values it had when it was retired.The revive response is
{"data": {"type", "id"}, "links", "meta": {"did_revive"}}and has noattributeskey, less than any other write returns (probe 024).did_reviveisfalseon a row that was already live, at 200, which is the only way to tell a revive from a no-op.?fieldsis ignored here as on every other write. On the probed site a successful revive logged oneShotgun_Shot_Revivalevent and a no-op logged none./<field>is not a cheap single-field read. Every non-file field is a 400 naming the field:Field Version.code is not an image or attachment.A dotted path is a 406 with a one-byte body, because the last dotted segment is parsed as a format extension. Use?fields=onGET /entity/<type>/<id>for anything else.?alt=originaland?alt=thumbnailturn the same path into a download: a 302 to the presigned storage URL, which a redirect-following client fetches as the bytes.Rangeis forwarded to storage and answers 206 withContent-Range, so a client can read a header off a large movie without pulling the file.Rangewithoutaltis ignored and the field hash comes back at 200.relationships/<related_field>returns the identicaldataa normal read puts underrelationships, minus thelinks.relatedpointer, and it is not paged: a 60-link field answered all 60 rows with nolinks.next, andpage[size],page[number],fieldsandsortwere all accepted and ignored.It saves 233 bytes on a single entity link and 183 on 60 of them, so it is worth a call only when the link list is the whole request.
_update_last_accessedanswers 200 for auser_idthat does not exist and returns the same{data, links}either way, so nothing in the response says whether it did anything.On the probed site
Project.last_accessed_by_current_userreadnullbefore and after, and noEventLogEntrywas written, because that field is relative to the requesting user and a script is not the user it stamps. There is no read-back over REST; treat the call as write-only.The path is fixed to
projects.PUT /entity/shots/<id>/_update_last_accessedis a 404 with a nulldetail, andGETon the project path falls through to the file-field route and answersField 'Project._update_last_accessed' does not exist., which names a field nobody asked for.<format>is not validated..json,.xmland.txtall answer, and the extension sets the responseContent-Typewhile the body stays the same plain-text string. Drop the extension and the route stops matching: 404, code 103. Whether a successful export honours anything butcsvis unmeasured, because nothing exported.Export is off by default and there is no field that says so. A page id that does not exist answers
Trying to perform export for retired Page id=999999999, and a non-numeric id is read asid=0, so a 422 does not distinguish a missing page from a page whose view is not marked exportable.On the probed site 52 pages across all 27
page_typevalues answered 422 and none answered 200;Pagehas noexportablefield and the flag is not in the layoutsettings_jsonprobe 023 reads, so a client cannot discover which pages will work without trying each one.
corpus/findings/048_one_record_beyond_crud.md
059_dotted_path_type_check
The middle segment of a dotted path is checked against the field's valid_types in a projection and against the schema alone in a filter: the projection drops the key at 200, the filter 400s. [partial]
not measured: whether a filter through a type outside valid_types is evaluated or matches nothing; no row on a read-only project links one, so it needs a write
Two parsers, two rules. A projection checks the middle segment against the field's
valid_types; a filter checks it against the site schema and ignoresvalid_types.path in ?fieldson the left of a filter middle in valid_types, leaf realthe value, or nullwhen the row links another type (field_types/entity)evaluated middle a real type outside valid_typeskey absent, 200 200, 0 rows here middle names no type key absent, 200 400 API read() Version.entity.Bogus.code doesn't exist.leaf missing on the middle type key absent, 200 400 API read() Version.entity.Shot.bogusfield doesn't exist.A template that writes
entity.Asset.codeagainst a Shot is not an error a client can catch on read. It isnullwhile the type is avalid_typesmember and the key is gone once it is not, which is the same quiet drop a bogus?fieldsname gets (probe 004). Read the link out ofrelationshipsand branch ondata.typerather than asking for a path per type.To check a path before shipping it, send it once as a filter. The type and the leaf are both validated there and the 400 names the whole path,
Version.entity.Bogus.code, atcode: 103.api3_array,api3_hashandGET ?fieldsreturned the same key and the same value for all eight paths, so the vendor Content-Type switches nothing here either (probe 004).
corpus/findings/059_dotted_path_type_check.md
060_entity_dict_name
The name in an entity dict is the target's cached_display_name, filled on every type measured, single and multi alike. Read it, not the per-type identity field, and expect decoration.
name is cached_display_name, not the identity field. Every type Version.entity accepts on the
probed site, plus the three a client reaches through another field. One row per type, each created in
the sandbox and linked with a single PUT.
| target type | identity field | dict name equals |
|---|---|---|
| Asset, Level, MocapTake, Reel, ShootDay, Shot, Sequence, Launch, Camera, Slate, SourceClip | code |
the identity field |
| the site's two enabled CustomEntity slots (probe 008) | code |
the identity field |
Task, reached through sg_task or written into entity |
content |
the identity field |
Note, reached through notes or written into entity |
subject |
the identity field |
Project, reached through project |
name |
the identity field |
| Delivery | title |
cached_display_name, which is #<id>: <title> |
Sixteen of the seventeen agree with the identity field because cached_display_name is a copy of it.
Delivery is the one that does not, and it is what a client renders in a picker, so read
cached_display_name and never reconstruct the identity field from it.
It is the one type-agnostic name. name was present and populated in all 17 dicts, and never null
except when the row itself was gone. A client holding a link needs no second call and no per-type map of
code against content against subject.
| where the link comes back | element shape |
|---|---|
entity, sg_task, project, user, created_by |
{id, name, type} |
notes, tasks, playlists, sg_ai_generated_from |
a list of {id, name, type} |
multi_entity elements are the same three keys with the same name (field_types/multi_entity).
Headers change nothing. GET /entity/versions/{id}, POST _search under
application/vnd+shotgun.api3_array+json and the same _search under ...api3_hash+json returned
byte-identical relationships blocks, confirming probe 004 on the dict's contents.
cached_display_name is readable, not filter-only. ?fields=cached_display_name and a _search
fields list both answered it on Task, Shot, Asset, Project, Version and Note, and it matched the dict
name in each. Task is the case that matters: the dict's name and the field cached_display_name are
the only two ways to name a Task, since name and code do not exist on it (entity_types/Task).
| asked for | Task, Shot, Asset, Version, Note | Project |
|---|---|---|
?fields=cached_display_name |
the display name | the display name |
?fields=name |
200, attributes empty |
200, name, a real field |
["name", "is", "x"] as a filter |
400 API read() <Type>.name doesn't exist. |
matches |
?fields=name is the silent drop probe 004 records: a client that asks for name on a Version gets 200
and nothing, which reads as "no display name" rather than "wrong field".
A gone target is dropped, never stale. Nothing holds a dead {id, name, type}.
| the link | after the target is deleted |
|---|---|
sg_task |
data is null |
playlists and every other multi_entity measured |
the element is removed from the list |
entity, when the target is the Shot the Version hangs off |
the Version itself is retired |
| a filter on any of the three, against the deleted id | 200, 0 rows |
- Destructive.
DELETE /entity/shots/{id}returned 204 and retired every Version linked to that Shot throughentity: the Version answered 404 on aGET, 0 rows in_search, and 200 withentitynull underoptions[return_only]=retired. Deleting a Task or a Playlist retires only itself. Read the children before deleting a parent; the 204 names nothing it took with it.
corpus/findings/060_entity_dict_name.md
064_hierarchy_expand_buckets
Dedupe children by path and keep the first. The __none__ bucket is repeated once per group, byte-identical every time, and its rows are disjoint from every group's. [partial]
not measured: how the web interface draws the repeated node. Its tree needs a session a person approves (probe 052), and the saved token had expired
Dedupe
childrenbypathand keep the first. The bucket is emitted once after every group and each copy serialises identically, key order included, so the first is the whole of it.The repeat count is the number of groups, not the number of ungrouped rows. On the probed site one project has 44 empty sequences and 44 unsequenced Shots: 44 groups that expand to
No Shots, and 44 copies of the one bucket that holds all 44 rows.has_children: trueon the bucket is a shape, not a count. A project with nothing ungrouped still lists it, and expanding it answers a single child of"kind": "empty"labelledNo Shots.A row is under the bucket or under a group, never both. Bucket rows and group rows summed to the project's own Shot count on both projects measured that way.
A grouping field with no rows hides every row under it. On the probed site a project with 13 Shots and no Sequence answers
/Project/<id>/Shotas one"kind": "empty"child labelledNo Shots, with no bucket among the children. The bucket path answers all 13 when asked for directly, so build it rather than trustingchildrento name it.Grouping by a list field repeats nothing:
/Project/<id>/Assetreturns one__none__child, last, with"kind": "list". The repetition measured here is the entity-grouped case._expandand_searchspell the same bucket differently, and both answer:_searchwritessg_sequence/__none__where_expandwritessg_sequence/Sequence/__none__. The label is templated off the segment, so the first readsShots with no __none__.
corpus/findings/064_hierarchy_expand_buckets.md