SG Ground Truth

Findings — write: creating and updating

How the API behaves in this part of a session. Each rule is the entry's own Teaches, copied whole.

API

011_create_project

A script user can create a Project with nothing but {"name": ...}, at 201, but the response echoes only 6 attributes, so read the project back if you need anything else.

  • {"name": "sandbox_show"} with Content-Type: application/json is a complete create: 201, no project template, no code, no dates.

  • On the probed site, GET /schema/Project/fields returns 42 fields and flags exactly one, name, mandatory: true. That count is site configuration, and a schema flag is not the create contract (probe 012), so it does not establish that name is the server's only requirement. This probe never posted a body without name; that POST, behind --write, would settle it.

  • Trap. The 201 body is not an entity read. It echoes 6 attributes (cached_display_name, created_at, landing_page_url, name, tracking_settings, updated_at); everything else, id apart, needs a GET on the new project.

  • name is flagged both mandatory and unique in the schema, so creating is not idempotent. Probe first (GET /entity/projects?fields=name) and reuse the hit, as this probe does. Custom fields silently become <name>_1 on a duplicate (probe 019); whether a Project name collides or duplicates is <unverified>.

  • A fresh project has no sg_status, so a picker must not filter on it (probe 018).

corpus/findings/011_create_project.md

012_create_version

The schema's mandatory flags are not the create contract: on every project-scoped type measured, project is required and the identity field is optional, server-generated and not unique.

  • Trap. /schema/Version/fields marks only code mandatory, yet a body without project 400s.

  • An entity link is a hash: "entity": {"type": "Shot", "id": N} creates, "entity": <id> 400s. project takes the identical shape, and reads return both under relationships (probe 004).

  • The 201 relationships block lists slots that were never set (cuts, playlists, tags), so its keys are not a record of your input. entity appears only when it was written, the one usable confirmation.

  • Both 400s recorded as null: the probe captured errors[0].detail and these errors have none. The status codes are verified; the message text is <unverified>.

The create contract, every type measured since. Version is not a special case. One row per card, entity_types/<Type>:

type identity field flagged mandatory actually required omitted identity becomes unique
Version code yes no New Version <id> no
Shot code yes no New Shot <id> no
Asset code yes no New Asset <id> no
Sequence code yes no New Sequence <id> no
Task content yes no New Task <id> unmeasured
Playlist code yes no New Playlist <id> no
PublishedFile code yes no New Published File <id> no
Note subject yes no nothing; the row has no title no
TimeLog none only id n/a n/a; description reads New Time Log no
Delivery title yes no New Delivery <id> no

For a project-scoped type, project is the requirement and is flagged mandatory: false; the identity field is flagged mandatory: true and is optional, generated by the server when omitted. {} and the identity field alone both answer 400 API create() missing 'project' attribute: with the body echoed. Nothing is unique on any of them, so keying on code is wrong on every type measured: match on id, and treat a code lookup as a query that can return more than one row.

Omitting the key and sending an empty one are different. {"code": ""} on a create is 400 code 104 Create failed for [Shot]: Cannot set identifier field to empty. (Shot) (entity_types/Shot), and Sequence answers the same message for "" and for null (entity_types/Sequence).

Site-wide types are the boundary, not the rule. Project has no project field at all, and its identity name is the one field flagged both mandatory and unique, so a create there is not idempotent (entity_types/Project).

editable is the same shape of flag and the same shape of wrong. created_at and updated_at are flagged editable: false on Note, Reply, Task and Version, and a create body sets both (probe 070). Neither flag describes the create path; send the body and read the 400.

corpus/findings/012_create_version.md

024_read_after_write

Every write ignores ?fields. A create returns what you sent plus the server defaults, an update returns the whole record, and neither resolves a dotted path, so re-read for those and after an upload.

  • There is no conditional write. If-Match, If-Unmodified-Since and If-None-Match are ignored and the update applies at 200, though a GET returns a weak ETag.

  • Echoing updated_at back is refused with API update() Task.updated_at is editable on create only. So a read-then-write guard narrows the race window and never closes it, and exactness needs serialisation outside the API (recipes/005).

operation the response returns it omits how to get the rest
POST /entity/<type> the fields of the request body, the server-set ones (created_at, updated_at, cached_display_name), and every relationship as {id, name, type} every field left at its default, and every dotted path GET /entity/<type>/{id}?fields=...
PUT /entity/<type>/{id} the whole record, changed fields and untouched ones alike dotted paths only the same follow-up GET
PATCH /entity/<type>/{id} nothing: 404 with a null detail everything use PUT
batch create row the same subset a single create returns as a single create one GET per created id
batch update row the whole record, wrapped with links and status dotted paths only the same follow-up GET
batch delete row did_delete, id, uuid any field value nothing to re-read
_upload complete_upload 201 with a one-byte body the stored path and the final URL poll the field until it stops matching /images/status/transient/
DELETE /entity/<type>/{id} 204, empty body everything the id answers 404 from then on
  • Trap. ?fields on a write is accepted and ignored, plain names and dotted paths alike, with no error, the same quiet drop a bogus ?fields name gets on a read (probe 004).

  • The reported create-versus-update asymmetry is real but inverted: the create response is the thin one. Neither verb resolves project.Project.name or entity.Shot.code, and both return the link's own name under relationships, so a second call is needed only for a linked entity's other fields.

  • On the probed site a Version create answers 9 attributes and 15 relationships and an update answers 52 and 20. The counts are site-specific; the ratio is the rule. A create body of 3 extra fields answered 12 attributes, so the create response is what you sent plus the server defaults, not a fixed list.

  • A batch endpoint does exist, undocumented: POST /entity/_batch, and it takes plain application/json, not the vendor Content-Type _search requires (probe 004).

    {"requests": [{"request_type": "create", "entity": "Version", "data": {...}},
                  {"request_type": "update", "entity": "Version", "record_id": N, "data": {...}},
                  {"request_type": "delete", "entity": "Version", "record_id": N}]}
    

    entity is the singular schema name, not the URL slug; the id key is record_id, and entity_id is read as 0 and 404s. One failing row rolls the whole batch back: a good create paired with an update of id 999999999 left 0 rows behind. The response is data, one row per request, in order.

  • Transcoding is the only write whose result is not readable at all on return (probe 013). On the probed site a 16x16 PNG thumbnail stayed at /images/status/transient/thumbnail_pending.png past t+21s and resolved by t+42s, so poll on the path prefix and never on elapsed time.

  • Unsettled. One team reports a newly created Version's linked-entity field reading back empty at the moment of its own creation event, and built a diagnostic to fail loudly on it.

  • This repo has no event listener, so the claim is untested here and a negative result would prove nothing. Settling it needs a listener reading the entity from inside the event callback and comparing against a read a second later.

corpus/findings/024_read_after_write.md

045_webhooks

The hook contract validates the url and the entity type, and silently accepts a field name, a project id and an entity-type count it will never honour. test_connection answers 204 for any uuid. [partial]

not measured: No entity event reached a hook on the probed site, so the delivery payload, the X-SG-SIGNATURE and x-sg-event-batch-* headers, and batch_deliveries, are all unrecorded.

sent result
a field the type does not have, in update 201, and the hook can never fire
two entity types in one hook 201
a project id that is not there 201
an action name the API does not have 400 entity_types is not valid
an entity type the site does not have 400 entity_types is not valid
  • The url validator resolves the host and ignores the scheme. Every unroutable target is refused with one message naming reserved and internal addresses, https://<name>.example.com included, while ftp://example.com/... answers 201. Passing validation means the host resolved, not that the hook can be delivered to.

  • test_connection answers 204 for a uuid that is not a hook. It confirms nothing: not that the hook exists, not that the endpoint is reachable, not that anything was sent. Do not use it as a health check.

  • POST /webhook/hooks refuses the vendor array content type that _search requires, at 415 naming application/json. The webhook family takes plain JSON only (004_array_vs_hash).

  • The two 404 shapes are the parser, not the lookup: a well-formed uuid that names nothing answers code 104 with detail naming it, and a segment that is not a uuid answers code 103 with detail null.

  • A delivery record is written whether or not anything answers. A hook pointed at a dead host records status: "delivered" with response_code: 0 and an empty body. delivered means dispatched, not received. Never read that status as confirmation a consumer got the payload; read response_code, which is 0 when nothing replied.

  • A hook's own status change is delivered to the hook. Setting status to disabled and back writes one delivery record each. Its event type is not in the guide's list of 39 custom events:

    {"data": {"id": "0", "event_type": "Webhook_Status_Change", "event_log_entry_id": 0,
              "webhook_status": "disabled", "previous_webhook_status": "active",
              "meta": {"type": "webhook_status_change", "source": "client",
                       "old_value": "active", "new_value": "disabled"}},
     "timestamp": "2026-09-04T18:47:18Z"}
    

    id is "0" and event_log_entry_id is 0: this is generated by the webhook service itself and has no row in the event log behind it. A consumer must tolerate it, because no subscription asks for it and every toggled hook is sent one.

  • On the probed site, entity events reach no hook, and this is not a REST problem. A hook created in the web interface and one created over REST behave identically: active, correctly subscribed, and no delivery record for any entity change or for test_connection, while Webhook_Status_Change on the same hook in the same minute records normally.

  • Two public endpoints were tried, a tunnel proven reachable in-process and webhook.site. So the delivery recorder runs and the entity-event feed into it does not. Diagnose a silent hook by toggling its status: a record proves the pipeline is alive and isolates the fault to the event feed.

  • Because no entity event was delivered, X-SG-SIGNATURE, the entity payload, the x-sg-event-batch-* headers, batch_deliveries, and PUT /webhook/deliveries/<record_uuid> and redeliver are all unprobed.

  • Everything here uses entity_types. The second subscription mode the 400 names, event_type, the fourth lifecycle action revive, and the entity families the guide excludes are all measured in 050_webhook_subscriptions.

corpus/findings/045_webhooks.md

050_webhook_subscriptions

entity_types and event_type are mutually exclusive and one 400 covers giving neither and giving both. revive is a fourth action, and every entity the guide calls excluded is accepted at 201.

  • The two modes are exclusive, and one message covers both ways of getting it wrong. entity_types either entity types or event type is required is returned for a body with neither and for a body with both. Read it as "exactly one of these", not as "you are missing one".

  • Every entity the guide calls excluded is accepted at 201. A hook on ApiUser, on EventLogEntry, or on a connection entity such as AssetShotConnection is created, is active, and reads back intact. The documentation says it will never fire. Nothing in the API says so, so the create is not the place you will find out.

  • revive is a fourth lifecycle action alongside create, update and delete, and it is the counterpart to the logical delete in 040_field_revive. retire is not an action: the API spells the same operation delete.

  • A bogus event_type does not enumerate the legal ones. event_type is not valid names nothing, unlike the filter operators, which answer a bogus relation with the full list (017_filter_operators). The 39 custom events are readable from the guide and from nowhere in the API.

  • Type errors on event_type are specific where the value error is not: a list answers must be a string and an empty string answers must be filled.

  • batch_deliveries and validate_ssl_cert round-trip on create and are readable back.

corpus/findings/050_webhook_subscriptions.md

058_local_storage_roots

One create fills every local_path_* the storage row defines, whichever platform's root the path was under. The server picks the deepest matching root, and no conditional-write header is honoured.

  • One create fills every platform the row defines. A Mac artist publishing under mac_path gives a Linux farm a working local_path_linux in the same write, and the join runs the same way for a path given under the Linux or Windows root: the server strips whichever root matched and re-joins the remainder onto all three.

  • local_path_windows comes back with backslashes and the drive letter exactly as the row spells them, even though a backslash in the request is refused (recipes/004_register_published_file).

  • A platform reading null is a root the row leaves unset (probe 021), never a property of the write.

  • A Windows root is matched with forward slashes. Z:/zzprobe_058_a/seq/plate.v001.exr resolved against windows_path Z:\zzprobe_058_a, so a client normalises separators before sending and still reaches a drive-letter root.

  • The deepest matching root wins, not the oldest row. With /zzprobe_058_n and /zzprobe_058_n/sub both defined, a path under sub resolved to the sub row in both creation orders, so id order does not decide it. Two rows on the identical root resolved to the higher id.

  • A client that means one specific storage sends {"relative_path", "local_storage"}, which names it outright, rather than {"local_path"}.

  • Nothing in the response signals the choice beyond the id. path.local_storage and path_cache_storage hold the same row and there is no confidence, no candidate list and no warning, so a client checks by comparing that id against the storage it intended (recipes/004_register_published_file step 5).

  • There is no conditional write. All six headers were accepted and ignored, each at 200 with the write applied. The GET does return a weak Etag, so it looks like a precondition is available and no request built on it is honoured. The read-then-write race on version_number cannot be closed at the API; it stays a client convention.

corpus/findings/058_local_storage_roots.md

069_client_note

client_note cannot be set over REST: true on create is 400 and any PUT is 400 editable on create only. sg_note_type: "Client" is the one marker a caller can write.

Two refusals, one field. The create path answers Client Notes can not be created through the API, and the update path answers Note.client_note is editable on create only. Read together they close every route: the only call allowed to set the flag refuses true, and false is what an omitted key already stores. The schema's editable: false is right here, unlike created_at (probe 070). sudo_as_login changes nothing; the refusal is on the API, not on the identity.

sg_note_type is what a REST caller can write. Both fields are stock (visible.editable false, probe 056). sg_note_type is an ordinary list: a value outside valid_values is 400 with the vocabulary in the error, a PUT is 200, and is filters count it. On the probed site the vocabulary is Internal and Client; read it from the schema, never assume it. Setting it to Client leaves client_note false, so a client written this way is invisible to a filter on client_note.

Filter both when listing client-facing Notes. A Note the web application flagged and a Note the API typed are two disjoint sets: client_note is true finds the first, sg_note_type is "Client" the second. On the probed sandbox, 0 and 108 rows.

corpus/findings/069_client_note.md

070_authored_timestamps

A create body sets created_at and updated_at and they read back exactly, on Note, Task and Version, though the schema flags both editable false; every PUT on either 400s. [partial]

not measured: Whether an EventLogEntry create can date itself is untried: that row cannot be deleted afterwards (probe 025), so the probe did not spend one.

editable: false does not describe the create path. Both timestamps are flagged editable: false on all four types and both are accepted in a create body. This is the same inversion probe 012 found on mandatory, where the one field flagged mandatory on a Note is optional and project, which is not flagged, is required. The server's own error says which half of the flag is real: is editable on create only, not is not editable. Read editable: false as "not editable by a PUT" and test the create.

verb created_at updated_at
POST stored as sent stored as sent, on the types that have it
PUT 400 is editable on create only 400 is editable on create only

An authored date is the real one. It is what the row reads back, what created_at filters and sorts on, and what less_than selects: nothing keeps a separate wall-clock insert time. An import writes history that queries correctly, and a bug writes rows that a "created this week" feed can never see.

null is accepted and leaves the row undated. {"created_at": null} answers 201 and the field reads back None, so every created_at filter and every sort on it drops the row. Omit the key rather than sending null on a create built by dropping empty values.

  • updated_at is not on Reply at all: the create 400s with API create() Reply.updated_at doesn't exist. created_at is there and takes a value like the rest.

  • The value shapes are the date_time write shapes exactly (field_types/date_time): ISO 8601 with or without an offset, a date-only string meaning midnight UTC, an offset normalised to UTC, and "YYYY-MM-DD HH:MM:SS UTC" refused, which is the spelling the create's own 201 echo uses.

  • The 201 echo and the re-read disagree about the format. Three of the four types echo 2019-03-04 05:06:07 UTC, Task echoes ISO 8601, and a GET on any of them returns ISO 8601. Parse the re-read, not the echo.

  • sudo_as_login changes nothing here: a person's create dates itself exactly as the script's does.

  • Nothing measured here reaches the event log. A create still writes an EventLogEntry dated now, and that entry cannot be deleted (probe 025), so a back-dated import leaves a forward-dated audit trail.

corpus/findings/070_authored_timestamps.md

Every entry on this site is the output of a probe in probes/. The corpus is generated by running those probes against a live Flow Production Tracking site, not written from memory.

Not affiliated with or endorsed by Autodesk. Flow Production Tracking is their product; this is an independent record of how its REST API answers.