# 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.

## 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:

  ```json
  {"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`
