{"openapi":"3.1.0","info":{"title":"uw — the drafting page and the scoreboard","version":"2.1.0","description":"The read behind zpe.vc/uw: one published snapshot of the local Upwork store (the scoreboard, the wallet with the moment it was read, and every job's suggested letter), plus the drafts he saves through this page. The Worker holds no handle to the ledger: every number here was pushed by `make publish` on the machine that owns it, and the drafts are pulled back by `make pull`. Identity is the door's (auth.zpe.vc), never this app's: the request carries the door's session cookie, and the account it names is recorded on every draft. A fleet key minted at the same door (`Authorization: Bearer`, grant `uw`) opens the reads and nothing that writes. A job is named by a short ref, never by its ledger id, and no client name is published at all."},"servers":[{"url":"/uw","description":"uw on zpe.vc"}],"tags":[{"name":"read","description":"What the page prints, answered as JSON from the same read."},{"name":"drafts","description":"His letters. A save appends a version and never overwrites one."},{"name":"events","description":"What changed, in the record's own order. One row per state change, written in the same transaction as the change it describes."},{"name":"sending","description":"The queue the machine works. This Worker records the intent and the answer; the Upwork credential and the Upwork calls are on the machine that owns the ledger."}],"security":[{"cookieAuth":[]}],"paths":{"/_/api/v1/state":{"get":{"operationId":"uwState","tags":["read"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"summary":"The scoreboard, the published snapshot's job list, and one job's two panes and history.","description":"One read for the page and for a client: the HTML at /uw/p and this answer are built from the same state, so the two cannot disagree about a number. The drafted body is included, because the client's job is to show it. A key reads exactly what the owner's page reads: the answer's `scope` names `reads: \"publisher\"` and the account whose shelf answered.","parameters":[{"name":"job","in":"query","required":false,"description":"A job's short ref. Omitted, the newest live proposal answers.","schema":{"type":"string","pattern":"^[A-Z0-9]{6,20}$"}}],"responses":{"200":{"description":"The read.","content":{"application/json":{"schema":{"type":"object","required":["ok","schema","published_at","jobs"],"properties":{"ok":{"type":"boolean","const":true},"schema":{"type":"string","description":"The snapshot's version word, `uw/1`."},"published_at":{"type":"string","format":"date-time"},"snapshot_id":{"type":"integer"},"window":{"type":"object"},"scoreboard":{"type":"object"},"corpus":{"type":"object"},"jobs":{"type":"array","items":{"type":"object"}},"job":{"type":["object","null"]},"agent":{"type":["object","null"]},"owner":{"type":["object","null"]},"history":{"type":"array","items":{"type":"object"}},"against_agent":{"type":["object","null"]},"scope":{"type":"object","description":"What the door said about this caller: the account, the organisation it named (null when the membership is ambiguous or there is none), the role, which credential said so, and which shelf that opens. The page draws this state; it is never a default. `via: \"token\"` is a key the fleet holds, and its shelf is `publisher`: the account this deployment publishes for, with `organization_id` null because a key names no organisation.","properties":{"account_id":{"type":["string","null"]},"organization_id":{"type":["string","null"]},"organization_name":{"type":["string","null"]},"role":{"type":"string"},"via":{"type":"string","enum":["session","token"]},"reads":{"type":"string","enum":["organisation","own","publisher","none"]}}}}}}}},"401":{"description":"No door session and no key: sign in at auth.zpe.vc, or send the fleet key as a bearer token.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"A key without the `uw` grant, or a key on a method this surface only writes.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"The door did not answer, or nobody has published a snapshot yet.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/_/api/v1/save":{"post":{"operationId":"uwSaveDraft","tags":["drafts"],"summary":"Append his version of one job's letter, once per idempotency key.","description":"The only write on this surface. It appends a row and never updates one: a version that can be edited is a version that can be made to agree with whatever happened afterwards. The page's own button posts the same fields as a form and is answered with a 303 back to the pane; a client posting JSON is answered 201 with the row it wrote.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"The caller's retry handle. The same key twice replays the first answer and writes nothing.","schema":{"type":"string"}}],"requestBody":{"required":true,"description":"The letter, the job it belongs to, and an optional one-line note.","content":{"application/json":{"schema":{"type":"object","required":["job_ref","body"],"properties":{"job_ref":{"type":"string","description":"A short ref the snapshot lists. Any other ref is refused."},"body":{"type":"string","minLength":1,"description":"The letter, as plain text."},"note":{"type":"string","maxLength":200,"description":"What changed and why, in one line."},"idempotency_key":{"type":"string","description":"The same handle as the header, for a client that cannot set one."}}}}}},"responses":{"201":{"description":"The version this call appended, and what it changed against the suggestion.","content":{"application/json":{"schema":{"type":"object","required":["id","version","words"],"properties":{"id":{"type":"string","description":"The row's id in this Worker's shelf, `w<id>`."},"version":{"type":["integer","null"],"description":"His ordinal over the merged history."},"words":{"type":"integer"},"at":{"type":"string","format":"date-time"},"against_agent":{"type":["object","null"]}}}}}},"303":{"description":"The form's answer: the pane, with the saved version printed under the button."},"400":{"description":"The body names no job this snapshot lists, or carries no letter.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"No door session.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"A bearer key: a save names the person who wrote it, and this route refuses a key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/_/api/v1/drafts":{"get":{"operationId":"uwDrafts","tags":["drafts"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"summary":"The drafts this session may read, oldest first.","description":"The session's own shelf: the organisation the door named and the role it gave decide which rows answer, so a second organisation sees none of the first's. A client reading this sees exactly the versions the page would draw. A KEY reads the publisher's shelf, which is the owner's: the same rows his page draws. `make pull` uses the token-only pull at `/_/publish/drafts`, which is scoped to the deployment's own publisher.","parameters":[{"name":"job","in":"query","required":false,"description":"One job's short ref. Omitted, every job's drafts answer.","schema":{"type":"string"}},{"name":"since","in":"query","required":false,"description":"Only rows with an id greater than this, for a pull that keeps its place.","schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"The rows.","content":{"application/json":{"schema":{"type":"object","required":["ok","drafts"],"properties":{"ok":{"type":"boolean","const":true},"drafts":{"type":"array","items":{"type":"object","required":["id","job_ref","body","words","account_id","organization_id","created_at"],"properties":{"id":{"type":"integer"},"job_ref":{"type":"string"},"body":{"type":"string"},"words":{"type":"integer"},"note":{"type":["string","null"]},"account_id":{"type":"string","description":"The door's principal id of the person who wrote the version, never an email."},"organization_id":{"type":["string","null"],"description":"The organisation the door named on the session that wrote the row, null when it named none. It records what the version was written under; who may READ it is the session's roster-derived claim and role, never this column."},"created_at":{"type":"string","format":"date-time"}}}}}}}}},"401":{"description":"No door session.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/_/api/v1/events":{"get":{"operationId":"uwEvents","tags":["events"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"summary":"This account's record after one event's id, oldest first, one page at a time.","description":"THE RECORD, READ FORWARD. Every state change writes one row to this account's outbox in the same transaction as the change it describes, and this read walks those rows by the event's own id: a keyset, never an offset. `since` names a row rather than a position, so an event written between two pages is in the page it belongs in instead of shifting one out of it, and a reader that follows `next_cursor` sees every event once and none twice. Ordering is by id, which is monotonic, so two events written inside the same second — or the same minute — keep the order they were written in. `next_cursor` is null when this page is the last one the record holds; pass it back as `since` to continue. A `since` that is not an id this account holds is REFUSED, because a cursor silently read as \"the beginning\" makes a consumer re-read the record and never learn that it had. The record is the account's own: a session reads what it wrote, and a key reads the publisher's shelf. An event carries ids, refs and the numbers that moved, never a letter's body.","parameters":[{"name":"since","in":"query","required":false,"description":"An event's own id: the answer begins after it. `0` starts at the oldest event this account holds, and is the only value that is not looked up.","schema":{"type":"integer","minimum":0,"default":0}},{"name":"limit","in":"query","required":false,"description":"How many events one page carries, 1 to 200. Omitted, 50.","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}}],"responses":{"200":{"description":"One page of the record, oldest first.","content":{"application/json":{"schema":{"type":"object","required":["ok","events","next_cursor"],"properties":{"ok":{"type":"boolean","const":true},"events":{"type":"array","items":{"type":"object","required":["id","kind","subject_ref","at","payload"],"properties":{"id":{"type":"integer","description":"The event's own id and the cursor this page is read by; passing it back as `since` resumes exactly after it."},"kind":{"type":"string","enum":["proposal.changed","draft.saved","send.requested","send.answered"],"description":"The closed list, published as `x-channel-envelope.kinds` and taken from that one constant. The same words are a CHECK on the table, so a fifth cannot be written."},"subject_ref":{"type":"string","description":"The row the event is about: a post's short ref for three kinds, a draft's id for `draft.saved`."},"at":{"type":"string","format":"date-time"},"payload":{"type":["object","null"],"description":"The envelope: `v`, plus the ids, refs and numbers that moved. Never a body. Null means the stored row is not JSON this build can read; the event still answers, and its id still advances the cursor."}}}},"next_cursor":{"type":["integer","null"],"description":"The last id on this page when the record holds more after it, and null when this page is the end of it."}}}}}},"400":{"description":"`since` is not an id this account holds, or `limit` is outside 1 to 200, or either is not a whole number.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"No door session and no key: sign in at auth.zpe.vc, or send the fleet key as a bearer token.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"A key without the `uw` grant.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/_/api/v1/send":{"post":{"operationId":"uwSendRequest","tags":["sending"],"summary":"Queue a send request: a prepare (Upwork's preview) or a confirm (the submit).","description":"THE WORKER CANNOT SEND, AND THAT IS THE DESIGN. The Upwork credential lives on the machine that owns the ledger, so this route records an INTENTION. A `prepare` asks the machine for Upwork's preview — the exact text, the Connects, and any screening questions — and a `confirm` names the prepare row whose preview the person saw. A runner on the machine claims the oldest pending row, calls Upwork, and writes `done` or `refused` back; the state is asynchronous by construction and is answered within about a minute (`wait_seconds` names the wait). Nothing here submits anything. A confirm carries no letter: the text a person authorised is the preview's, and a body posted with a confirm is refused.","requestBody":{"required":true,"description":"`kind` is `prepare` or `confirm`. A prepare names `job_ref`, `rate` (USD) and optionally `body` (the newest saved version is used when it is empty) and `answers` (a JSON list). A fixed-price prepare names BOTH terms — `estimated_duration` and `payment_mode`, and by milestone the `milestones` — or neither (the hourly path); half a pair is refused by the missing field's name and nothing is queued. A confirm names `prepare_id` and nothing else.","content":{"application/json":{"schema":{"type":"object","required":["kind","job_ref"],"properties":{"kind":{"type":"string","enum":["prepare","confirm"]},"job_ref":{"type":"string","description":"A short ref the snapshot lists."},"rate":{"type":"number","minimum":0,"description":"The bid, in whole USD."},"body":{"type":"string","description":"The letter. Omitted, the newest version saved here is prepared."},"answers":{"type":"array","items":{"type":"object"},"description":"Screening answers, as Upwork takes them."},"estimated_duration":{"type":"string","description":"How long the work will take. One of the snapshot's `send_terms.durations`; required with `payment_mode`."},"payment_mode":{"type":"string","description":"How it is paid. One of the snapshot's `send_terms.payment_modes`; required with `estimated_duration`."},"milestones":{"type":"array","items":{"type":"object"},"description":"For the milestone payment mode: `{description, amount, due_date?}` rows whose amounts add up to the bid."},"prepare_id":{"type":"integer","description":"The prepare row this confirm belongs to. Required for `confirm`."},"room_ref":{"type":"string","description":"The room that asked, when a room asked: the channel writes it to `send_origin` in the same batch as the request, so which room a send came from is a row and not a recollection. Absent on the drafting page, which names no room."}}}}}},"responses":{"201":{"description":"Queued. The row's id, its state (`pending`), and the wait the page prints.","content":{"application/json":{"schema":{"type":"object","required":["ok","id","kind","state","wait_seconds"],"properties":{"ok":{"type":"boolean","const":true},"id":{"type":"integer"},"kind":{"type":"string","enum":["prepare","confirm"]},"state":{"type":"string","enum":["pending"]},"job_ref":{"type":"string"},"wait_seconds":{"type":"integer","description":"How long the runner's queue takes: one tick."}}}}}},"303":{"description":"The page's own form answer: the pane, with this request's state drawn under it."},"400":{"description":"The ref is not in the published snapshot, the rate is missing, half a fixed-price pair is named, there is no letter, or a confirm carries a body.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"No door session.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"A bearer key: a send request names the person who asked, and this route refuses a key. Queue a prepare with the owner's own session.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"get":{"operationId":"uwSendRequestState","tags":["sending"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"summary":"One send request's state, for the page and for a client polling it.","description":"The row the page writes is the row the page polls. `state` is `pending`, `claimed`, `done` or `refused`, and it is written only by the runner: a client that reads `done` is reading what the machine reported, never a guess made here. A refused row carries the sentence that refused it (`result.reason`) and no preview. A KEY reads the publisher's rows, which is how the fleet puts the exact letter and Upwork's preview in front of the owner before anything is sent.","parameters":[{"name":"id","in":"query","required":false,"description":"One request's id. Omitted, the newest for `job` answers.","schema":{"type":"integer"}},{"name":"job","in":"query","required":false,"description":"A job's short ref: the newest request for it.","schema":{"type":"string"}}],"responses":{"200":{"description":"The request, as the queue holds it.","content":{"application/json":{"schema":{"type":"object","required":["ok","request"],"properties":{"ok":{"type":"boolean","const":true},"request":{"type":"object","properties":{"id":{"type":"integer"},"kind":{"type":"string","enum":["prepare","confirm"]},"state":{"type":"string","enum":["pending","claimed","done","refused"]},"job_ref":{"type":"string"},"requested_by":{"type":"string","description":"The session's principal: the person who asked."},"at":{"type":"string","format":"date-time"},"claimed_at":{"type":["string","null"],"format":"date-time"},"run_by":{"type":["string","null"],"description":"The runner that took it, named by its claim."},"rate":{"type":["number","null"]},"body":{"type":["string","null"],"description":"The letter, on a prepare."},"terms":{"type":["object","null"],"description":"The fixed-price terms this row carries, as the page named them; null carries none."},"preview":{"type":["object","null"],"description":"Upwork's preview, once a runner has prepared one."},"result":{"type":["object","null"],"description":"What the runner reported, refused sentences included."},"prepare_id":{"type":["integer","null"]},"wait_seconds":{"type":"integer"}}}}}}}},"401":{"description":"No door session.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"This account wrote no request at that address.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}}},"components":{"securitySchemes":{"cookieAuth":{"type":"apiKey","in":"cookie","name":"__Secure-better-auth.session_token","description":"The door's own session cookie, set by auth.zpe.vc when a person signs in. This app never sets it and cannot mint one; it forwards the cookie to the door and trusts only the door's answer."},"bearerAuth":{"type":"http","scheme":"bearer","description":"A key minted at auth.zpe.vc (the estate's fleet key is one). This app does not mint it and never reads it: `zpe-kit`'s guard asks the door what the key may do, refuses a grant that does not name `uw`, and counts the call. A key opens the READS only — `/state`, `/drafts` and `/events` — and reads the shelf this deployment publishes for (`PUBLISH_ACCOUNT`), because a key names no person and its own principal holds no letters. Every write stays the person's session's."}},"schemas":{"Problem":{"type":"object","description":"RFC 9457 problem+json.","required":["type","title","status"],"properties":{"type":{"type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"}}}}},"x-channel-envelope":{"name":"channel.event","version":1,"kinds":["proposal.changed","draft.saved","send.requested","send.answered"],"fields":["id","kind","subject_ref","at","payload"],"payload":{"version_key":"v","version":1}},"x-surface":{"app":"uw","base":"/_/api/v1","document":"/_/api/v1/openapi.json","generated_by":"src/spec.ts buildDocument"},"x-zpe-routes":7,"x-zpe-documented":6,"x-zpe-internal":["GET /_/api/v1/openapi.json"]}