# Approvals

Approving is the one step only a workspace owner takes, with their passkey. It's how anything gets scheduled or posted
now, and the server enforces it: a job is created or run only from an approval

- made by an owner of the job's workspace, with a passkey of theirs,
- whose signed challenge covers the snapshot's SHA-256,
- whose snapshot carries exactly the job's payload, for a connection in that workspace,
- that hasn't been voided by an edit.

The scheduler checks all of that again when creating a job and right before it posts, including cryptographic
verification of the retained passkey assertion against the approver's current registered public key. It checks the
snapshot's challenge, credential id, origin, RP ID, user presence and user verification. Missing, altered or invalid
evidence fails the job with `error_code: "approval_refused"`; nothing reaches the platform. Waiting hand-first threads
are checked before looking for post 1 too, so an invalid or revoked approval stops discovery reads as well as posting.

Approvals already present when migration `0022_approval_signatures.sql` runs have `signature_version: 0` and retain the
previous challenge-and-snapshot checks, without a new cryptographic verification. Every later approval defaults to
version 1 and requires the full assertion; database triggers prevent a new row from claiming version 0 or an existing
row from changing versions. The historical row's identity, snapshot and retained evidence are immutable too; only its
voiding fields remain editable, so rewriting an old approval cannot reuse its exception for different content. This
historical exception is not based on an editable timestamp or missing evidence.
Both versions still require the approver's live passkey record and workspace ownership: removing a passkey, including
an email squatter's key when the real owner proves their email, stops its pending approvals from posting. Retained
assertion bytes are not a substitute for a revoked credential.

This is verification of an already-consumed approval, not another sign-in: it neither advances nor compares against the
passkey's current sign counter. Later legitimate taps don't invalidate an earlier approved job. A failed signature
never falls back to the historical checks.

## Agents ask, owners approve

An agent with the `request-approval` scope asks for one or more checked drafts at once (a plan):

```json
POST /api/v1/workspaces/{workspace}/approval-requests
{ "items": [{ "draft_id": "drf_…", "rev": 3 }, { "draft_id": "drf_…", "rev": 1 }], "note": "Launch thread and the LinkedIn post." }
```

Each draft is checked first; if any has problems, nothing is requested and the answer lists them (`check_failed`). A
draft without a `mode` is checked as what the approval page will do with it: `post_now` with no `post_at`, `schedule`
with one (or with `by_hand: "first"`), `approve` with `by_hand: "all"`. The answer has `approval_url`, the page where
the owner approves the plan with one passkey tap. The drafts move to
`approval_requested`, and the owner sees them, with the note, in `GET …/approval-requests` and in the event log
(`draft.approval_requested`). Editing a draft takes it out of the request.

An agent with `drafts` and `request-approval` can do it all in one call: `POST …/posts` with the drafts' bodies
(`{ "drafts": [{ "kind": "x_post", "connection_id": "con_…", "body": { "text": "…" } }], "note": "…" }`) creates them,
checks each, and requests approval for the plan when every check passes, answering `approval_url`, `drafts` and
`checks`. When a check fails it requests nothing and answers `ok: false` with each draft's problems and fixes; send the
fixed drafts again by `draft_id` and `rev`. An `Idempotency-Key` header makes a retried call answer what the first one
did instead of making the drafts twice. It's the same request as `POST …/approval-requests`, so it approves, schedules
and posts nothing either.

## Send back with a note

A signed-in workspace owner can send one or more requested drafts back without a fresh passkey tap:

```json
POST /api/v1/workspaces/{workspace}/approval-requests/send-back
{ "items": [{ "draft_id": "drf_…", "rev": 3 }], "note": "Lead with the result, not the feature list." }
```

Every selected draft must still be waiting for approval at that rev; otherwise nothing is sent back. The answer has
`drafts`, now `changes_requested`, with `changes_requested_note` and `changes_requested_at`. Each rev advances, the
request is removed and prior approvals are voided. A passkey challenge prepared before the send-back no longer works.
This creates no approval or job and never schedules or posts. API tokens and editor sessions cannot do it.

In the product it's Send back, beside Approve on the approval sheet: a one-line note and one tap, nothing more. In a
plan each draft has its own Send back and the footer's sends back the whole plan; a draft sent back keeps its place
as a line with its note while the rest can still be approved. The desk shows the draft as Sent back, with the note.

The agent reads the note through `getDraft`, `listDrafts` (including `status=changes_requested`) or `listEvents`, where
`draft.changes_requested` carries `data.note` and `data.rev`, with the owner as its actor. The CLI and both MCP servers
return the same feedback. After editing, the draft is `draft` again; check it and request approval again at its new rev.
The latest owner note remains on the draft after edits and renewed requests, separate from the agent's `request_note`;
earlier feedback stays in the event log.

## How approving works

1. The owner's app calls `POST …/approvals/options` with each draft's `draft_id`, `rev` and `mode`:
   - `approve`: the decision only (for drafts the owner posts by hand, or with no time yet);
   - `schedule`: post at `post_at` (or, for a thread with `by_hand: "first"`, as soon as post 1 is live);
   - `post_now`: post right away. TikTok posts go out only this way, and each TikTok item also carries the owner's
     choices from TikTok's posting screen (`tiktok`: privacy, interactions, disclosure; see [TikTok](tiktok)).

   The server freezes each draft into a snapshot and hashes it: SHA-256 over the snapshot's canonical JSON (keys
   sorted at every level). The snapshot holds the workspace, the draft and its rev, the platform, the account it posts
   as, the mode, the time, the content with each media file and its alt text, and the exact request that will be sent.
   It answers with the snapshots, their hashes, a nonce, and WebAuthn request options whose challenge is
   `base64url(SHA-256("approval:v1\n" + nonce + "\n" + hashes joined by "\n"))`. The app can recompute that challenge to
   show the passkey signs exactly what's on screen.
2. The owner taps their passkey once (user verification is required). One assertion covers every hash, so one tap
   approves the whole plan. The `/approve` page shows each snapshot (account, time, text, every media file and its alt
   text, quote and reply links, tags and their pages, link card, or the actual PDF pages and document title) and recomputes the hashes and the challenge before the
   button works. Its button follows the drafts: **Post now** for a draft with no time, **Post Tue 9:30 AM** for one with
   a time, **Approve** for a plan of times or a draft the owner posts by hand.
3. The app sends the assertion to `POST …/approvals`. The server checks the passkey belongs to the signed-in owner,
   uses the challenge once, freezes every draft again and requires the same hashes (`draft_changed` otherwise: nothing
   is approved), and checks them again. Then it stores each approval with its snapshot, hash, the approver, the
   passkey id and the full signed assertion (`authenticatorData`, `clientDataJSON`, signature, credential id and the
   challenge with its nonce and snapshot hashes), and creates the jobs for `schedule` and `post_now` (posting `post_now`
   ones before it answers). The scheduler re-verifies those bytes; it never treats a matching hash alone as a signature.

## Jobs

- `GET …/jobs` lists them: `waiting` (for post 1 by hand), `scheduled`, `held` (X credits exhausted or a login needs reconnecting), `posting`, `processing` (TikTok has it and
  is working on it, or LinkedIn is processing an uploaded video or PDF document), `posted`, `inbox` (TikTok put it in the creator's TikTok inbox as a draft), `failed`, `canceled`.
- A job posts at most 2 hours late; past that it fails instead of posting something stale.
- A failed or canceled X thread keeps its live prefix on the draft (`x_thread_live`: `connection_id`, `ids`, approved
  posting `parts`, `root` and `first_by_hand`). These parts are the approved expectations; an opening posted by hand
  may differ on X, so compare its live link. Database triggers capture successful IDs atomically with job results,
  including writes from an older Worker during deployment. The prefix has its own workspace-owned storage row, not
  another full text copy beside the draft body and Reset original. Reapproving the same draft continues after its last live
  post and replies to it, even after editing the unposted rest. If all parts are already live, reapproval finalizes and
  reads back without publishing again. Cancel a held or scheduled job before editing it.
- X cannot edit live text or media. Changing a live prefix, its root reply/quote or its posting connection is refused
  by default: the check's problem names the live posts that changed and carries the fix `repost-live` (no patch). To intentionally post the whole thread again, the owner alone can send `thread: { "repost_live": true }`
  on that item in `POST …/approvals/options`. The snapshot's `thread` (also in `payload.thread`) freezes a `live`
  baseline (`connection_id`, `ids`, `root`, `first_by_hand`, `parts_hash`) and `repost_live` into the passkey signature.
  `parts_hash` is SHA-256 of the canonical JSON of `x_thread_live.parts`; it commits to the original expectations
  without duplicating long text in the approval row. Hand-first reposts need a new opening, never a recorded live ID.
  No live posts means no extra choice or step. The approval page shows the live posts with their links ("2 of 4 live"),
  what continues under the last one, and the owner's choice to post the whole thread again.
  Agents cannot make this choice, approve it or start a job. A new draft is a separate review item, not a continuation.
- After an X post, the scheduler reads the posts back and compares text, hidden reply mentions, media, alt text, the
  reply chain and the quote. The result is the job's `result.check`; `POST …/jobs/{id}/verify` reruns it.
- LinkedIn posts aren't read back with the app's current member permissions. `result.check.available: false` and
  `error_code: "linkedin_readback_unavailable"` report this explicitly; media processing status is not a post match check.
- `POST …/jobs/{id}/cancel` (scope `cancel`) stops a waiting, scheduled, held or failed job, or a LinkedIn video or document still processing. It does not delete a live post; take-down below is owner-only.
- If Flaghand's prepaid X credits run out, a publishing or hand-first discovery job becomes `held`, with
  `error_code: "x_credits_exhausted"` and a plain explanation in `error`. Its approval, payload and already-posted
  thread ids stay intact; the credit refusal does not use its ordinary retry budget. The desk labels it **Held · X
  credits**, shows the reason, and offers **Retry held post** or **Unschedule**. Held drafts stay locked.
- Before scheduling, the draft check compares the post's time with the login's end time (including a known refresh-token
  expiry). Scheduled posts retain the existing hour of login headroom; immediate TikTok posts and hand-first discovery
  need a usable login now, consistently in the check and job creation. A `connect` fix includes
  `platform`, the pinned `connection_id` and `expires_at` (the full UTC instant); the approval sheet shows the reason
  and a Settings link. Reconnect is available in Settings at any time, not just the last week of a login.
- If a login expires or its renewal is refused when a job comes due, the job becomes `held` with
  `error_code: "connection_login_expired"` and its reason in `error`. The owner reconnects the same pinned account in
  Settings, then chooses **Retry held post** in the desk (TikTok: reopen its approval link for **Retry held post** or
  **Cancel held post**). Each held TikTok item in a plan has its own recovery link. Nothing posts just because a login
  was reconnected. Its original approval, payload and saved thread ids stay intact, and no publishing retry is consumed.
  A TikTok job is held only before an attempt could have reached TikTok; an unclear attempt is still never resent.
  Retrying also rechecks TikTok's current creator info and audit policy before initialization.
- After a top-up or reconnect, the owner can call `POST …/jobs/{id}/retry` from their signed-in session. This resumes the same job
  and rechecks the original approval and active workspace plan; API tokens cannot do it. Nothing replays automatically.
  An explicit retry resumes an ordinary held post now (even if its original time is over two hours ago); a hand-first
  thread resumes waiting, excluding the held duration from its six-hour wait. Already-posted parts are never resent.
- If X refuses only the live read-back, the job stays `posted`; `result.check.error_code` explains the credit outage.
  Retrying the check is not retrying the post. See [X credits and spend](x-ops) for operations and pricing.
- `POST …/jobs/{id}/first` (owner) checks now whether post 1 of a hand-first thread is live, or takes its link.

## Repost later on X

An `x_post` or `x_thread` body may include `"repost_after_hours": 24` (fractions are supported). Omit it for no
repost. For a thread it reposts **post 1**, not every reply and not a new copy of the thread. The preview and approval
show the delay; the snapshot freezes it in `content.repost_after_hours` and `payload.repostAfterHours`. Changing it
changes the signed hash. Agents may draft it, check and request approval; only an owner's passkey approval to post
now or schedule starts the job. An approval-only decision schedules nothing. `by_hand: "all"` cannot request an
automatic repost; hand-first threads can, timed from the opening's live creation time.

For example, create a draft with `{"kind":"x_post","body":{"text":"A post to share again tomorrow.","repost_after_hours":24}}`,
then check and request approval normally. Once the original job finishes posting, the same posting clock runs
`POST /2/users/{id}/retweets` when the opening's live time plus the approved delay is due. A partially failed thread
is not reposted while incomplete. A resumed thread uses its original opening's live time, not the time of its new replies.

`GET …/jobs`, `GET …/jobs/{id}` and a draft's `job` show `repost`: `state` (`scheduled`, `posting`, `posted`, `canceled`
or `failed`), `after_hours`, `run_at`, `posted_at`, and `error`. It is null when no follow-up was made. The original
job stays `posted`, with its original result and live check, even when the repost fails. Both posting clocks claim the
follow-up atomically. An unconfirmed, interrupted or refused repost is recorded as failed and never automatically
resent; check the account on X. The connection, current owner/passkey approval and active plan are checked again.

Cancel a pending repost with `POST …/jobs/{id}/cancel` (scope `cancel`); this never removes the original post.
Archiving the draft (the desk's trash action), deleting its draft record, or requesting take-down cancels the pending
repost. Restoring an archived draft does not restart it. At repost time the scheduler also checks the original is still
on that account, so a post deleted directly on X is not reposted. Cancellation cannot recall an already in-flight
repost; the cancel route refuses that state. Reposts are counted separately as `x_reposts` and priced by
`X_PRICES.repost` (X's **User Interaction: Create**, currently $0.015/request), plus the original-post existence read
at `X_PRICES.post_read`. Rates are estimates from [X's price table](https://docs.x.com/x-api/getting-started/pricing).

## Take down a live post

Only a signed-in workspace owner with a fresh passkey can take down a **posted** job. Tokens (including tokens with
every scope), editors and session cookies alone cannot. This works even after a plan lapses; it never starts a posting
job. TikTok's API cannot delete a published post: take it down in TikTok instead.

1. The owner's app calls `POST …/jobs/{id}/take-down/options`. It returns a `snapshot` with the workspace, job,
   platform, pinned connection/account and every published post id in the original job result; `snapshot_hash` is the
   SHA-256 of its canonical JSON. The WebAuthn challenge is
   `base64url(SHA-256("take-down:v1\n" + nonce + "\n" + snapshot_hash))`. Nothing is deleted yet.
2. The owner taps their passkey with user verification, then the app sends `{ "challenge_id": "…", "response": … }`
   to `POST …/jobs/{id}/take-down`. The assertion is single-use and tied to this job and workspace. If the ids or
   account changed since preparation, nothing is deleted; prepare again.
3. X uses `DELETE /2/tweets/{id}` for every part of a thread, tail first, including a hand-posted opening recorded in
   the job. LinkedIn uses `DELETE /rest/posts/{encoded urn}`. Flaghand never deletes media from its library or edits
   the original job result, approval, scheduled time or posting attempt count.

The response is the job with `take_down`: `requested_at`, `requested_by`, `passkey_id`, `complete` and
`posts` (`id`, `status`, `at`, and an `error` when refused). A part is `deleted`, `already_deleted` (HTTP 404), or
`failed`. `complete` is true only when every published id is confirmed deleted or already absent. The job's `state`
stays `posted` as posting history; read `take_down` to know what remains live. An interrupted or partly refused
thread stays incomplete. Retry through the same two calls with a new passkey tap: it skips confirmed parts and
attempts only the remainder, never republishes anything. A lost response is not reported as success; a retry may
observe that the post is already absent. These are platform deletion confirmations, not a guarantee that someone
else's copy, quote or cache disappears.

The journal is visible in job/draft reads and exports (including the agent's API, CLI and MCP reads).
`job.take_down_requested` records the signed decision; `job.take_down_progress` records each part together with its
job journal update. `job.taken_down` or `job.take_down_failed` records completion or an incomplete attempt. See
[X credits and spend](x-ops) for the per-part delete price. In the desk, a posted X or LinkedIn job's Take down (Take
down all 3 for a thread) runs both calls with one passkey tap and no other prompt; a part left live stays listed with
its reason, and Take down the rest retries only that. These endpoints are owner actions, never tools an agent may
execute for a real account.
