[Migration map](../reference/tool-name-migration.md).
# legacy_promotion_link_apply
See the [current correction and older-server guidance](../reference/README.md#current-input-casing-correction) before preparing new inputs; keep existing task inputs unchanged during recovery.
This implementation is a dependency of the documentation branch.
Compose legacy link creation or update selected sharing metadata and an optional original thumbnail. Default cards are read-only. There is no activation, bulk mutation, QR export or associated-page editor in this workflow.
## Access and input
Requires OWNER or DELEGATEE with MANAGE_CONVERSION_LINKS for the selected channel. Hosted source rules expose these tools and legacy-promotion-link recovery only on the exact selected-channel resource, not account resources. This is not deployed authorization evidence.
channelCode is a string of 1–512 characters, excluding literal dot/double dot, slash, backslash, ASCII controls and DEL. Selected input IDs are positive safe integers from authorized results. Strict inputs reject unknown fields. These legacy links are separate from [conversion links](../guides/conversion-links.md); do not exchange their IDs, revisions, share bases or task types.
| Input | Contract |
| --- | --- |
| channelCode | Required selected channel. |
| requestId | Required 8–80 ASCII letters, digits, underscores or hyphens. Keep original input with this ID. |
| expectedRevision | Required 64 lowercase hexadecimal characters; list.creationRevision for create, detail.revision for update. |
| action | create or update. |
| id | Forbidden for create; required selected positive safe integer for update. |
| settings | Complete create settings or at least one editable update field. |
| dryRun | Optional boolean; default false. true checks a plan without writes or a new task. |
Runtime refinements enforce action/field combinations beyond the flat JSON export. Replace sample IDs and zero revisions with selected read results.
```json
{
"channelCode": "selected-channel-code",
"requestId": "legacy-create-01",
"action": "create",
"expectedRevision": "0000000000000000000000000000000000000000000000000000000000000000",
"settings": {
"name": "Autumn promotion",
"title": "See our latest content",
"description": "Explore the channel.",
"keyword": "autumn_2026"
},
"dryRun": true
}
```
```json
{
"channelCode": "selected-channel-code",
"requestId": "legacy-update-01",
"action": "update",
"id": 123,
"expectedRevision": "0000000000000000000000000000000000000000000000000000000000000000",
"settings": {
"title": "Updated sharing title"
},
"dryRun": true
}
```
## Settings and preservation
| Field | Contract |
| --- | --- |
| settings.name | Trimmed 1–50 characters; required for create, optional for update. |
| settings.title | Trimmed 1–70 characters; required for create, optional for update. |
| settings.description | Trimmed 1–160 characters; required for create, optional for update. |
| settings.keyword | Required for create only: 3–30 ASCII letters, digits, underscores or hyphens; case is preserved. |
| settings.thumbnail | Optional original PNG/JPEG object; null/removal and replacement URLs are unsupported. |
| settings.thumbnail.filename | Basename of 1–240 characters with .png, .jpg or .jpeg extension; no slash, backslash, colon, ASCII controls or DEL. Use lowercase extensions with exported JSON-schema clients. |
| settings.thumbnail.base64 | Canonical bare base64, 4–13,333,332 characters; decoded original bytes must also pass validation. |
Update preserves omitted values and submits a PUT body containing complete name/title/description from requested values plus baseline, and thumbnailSrc only after a verified replacement upload. Keyword, targetMdid, targetPageId and unknown stored business fields remain read-only and must survive full readback. Only derived linkTime is excluded.
Creation uses a new chosen keyword and the source default thumbnail when omitted. Creation receipt requires numeric positive safe-integer id/channelId matching the selected channel, and an id absent from the creation baseline. The conversion workflow's canonical-string receipt correction does not widen legacy receipts. A missing durable identity is never adopted by name or keyword.
## Creation quota and availability
A new create plan checks a stable selected-channel quota and complete bounded ID baseline against creationRevision. Unknown policy returns LEGACY_LINK_POLICY_UNVERIFIED; exhausted quota returns LEGACY_LINK_QUOTA_REQUIRED. Before a task is created these return action_required with one human channel-plan destination and writesPerformed:false. No plan purchase, billing API or benefit change is sent.
Actual create rechecks quota/inventory before image work and again before submission. Keyword availability is a POST to the source /check endpoint accepting HTTP200 without a body; it runs before image work and again before creation. It may be checked again while untouched work continues. Availability is not a reservation and is not checked by dry run. A changed baseline requires a fresh read; task input/revisions cannot be silently changed. Existing-link update is not subject to the creation quota.
## Original thumbnail
Original PNG/JPEG bytes must be strictly below 10,000,000 decimal bytes (maximum 9,999,999). Filename/format/signature, canonical base64, byte length and SHA-256 are verified. No data URL, remote image URL, caller-supplied credential or forged receipt is accepted. Omission preserves an existing image. The authenticated MCP envelope is 16 MiB, independent of client/proxy limits and individual image bounds.
The host must configure resource gateway, bucket and region before an image plan/effect. Issuance delegates to the strict shared conversion grant200/201 adapter; upload uses original bytes and validates the receipt against metadata and generated location. Only receipt.location enters thumbnailSrc. Journals contain metadata/receipts, never base64, file bytes or temporary credentials. Upload/save are separate effects with no rollback or orphan deletion.
```json
{
"channelCode": "selected-channel-code",
"requestId": "legacy-thumbnail-01",
"action": "update",
"id": 123,
"expectedRevision": "0000000000000000000000000000000000000000000000000000000000000000",
"settings": {
"thumbnail": {
"filename": "cover.png",
"base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aDWQAAAAASUVORK5CYII="
}
},
"dryRun": true
}
```
## Inputs
| Field | Required here | Contract |
| --- | --- | --- |
| `action` | Yes | `create`, `update` |
| `id` | No | integer; greater than 0 |
| `settings` | Yes | object or object |
| `settings.name` | Yes | When `settings` is alternative 1: string; min length 1; max length 50 |
| `settings.title` | Yes | When `settings` is alternative 1: string; min length 1; max length 70 |
| `settings.description` | Yes | When `settings` is alternative 1: string; min length 1; max length 160 |
| `settings.keyword` | Yes | When `settings` is alternative 1: string; pattern `^[A-Za-z0-9_-]{3,30}$` |
| `settings.thumbnail` | No | When `settings` is alternative 1: one of: `local-file`, `more-parts`, `complete`, `cancel` (by `kind`) |
| `settings.thumbnail.filename` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `local-file`: string; min length 1; max length 240; pattern `^[^/\\:\x00-\x1f\x7f]+$`; The name of the file on this computer, with its extension. |
| `settings.thumbnail.byteLength` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `local-file`: integer; greater than 0; The exact file size in bytes (for example from `wc -c < FILE` or `stat`). |
| `settings.thumbnail.sha256` | No | When `settings` is alternative 1 and `settings.thumbnail.kind` is `local-file`: string; pattern `^[0-9a-f]{64}$` |
| `settings.thumbnail.uploadId` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `more-parts`: string; pattern `^upl_[A-Za-z0-9_-]{22}$` |
| `settings.thumbnail.fromPart` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `more-parts`: integer; minimum 1; maximum 10000 |
| `settings.thumbnail.uploadId` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: string; pattern `^upl_[A-Za-z0-9_-]{22}$` |
| `settings.thumbnail.parts` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: array; min items 1; max items 10000 |
| `settings.thumbnail.parts[].partNumber` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: integer; minimum 1; maximum 10000 |
| `settings.thumbnail.parts[].etag` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: string; min length 1; max length 200 |
| `settings.thumbnail.measured` | No | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: object; What the video measures on this computer, read with measureCommand. |
| `settings.thumbnail.measured.durationSeconds` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: number; maximum 1000000; greater than 0; Video duration in seconds (ffprobe format.duration). |
| `settings.thumbnail.measured.frameRate` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: number; maximum 1000; greater than 0; Frames per second (ffprobe avg_frame_rate, for example 30000/1001 = 29.97). |
| `settings.thumbnail.measured.width` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: integer; maximum 100000; greater than 0 |
| `settings.thumbnail.measured.height` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: integer; maximum 100000; greater than 0 |
| `settings.thumbnail.measured.frameRateMode` | No | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: `CFR`, `VFR` |
| `settings.thumbnail.measured.minFrameRate` | No | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: number; maximum 1000; greater than 0 |
| `settings.thumbnail.measured.maxFrameRate` | No | When `settings` is alternative 1 and `settings.thumbnail.kind` is `complete`: number; maximum 1000; greater than 0 |
| `settings.thumbnail.uploadId` | Yes | When `settings` is alternative 1 and `settings.thumbnail.kind` is `cancel`: string; pattern `^upl_[A-Za-z0-9_-]{22}$` |
| `settings.name` | No | When `settings` is alternative 2: string; min length 1; max length 50 |
| `settings.title` | No | When `settings` is alternative 2: string; min length 1; max length 70 |
| `settings.description` | No | When `settings` is alternative 2: string; min length 1; max length 160 |
| `settings.thumbnail` | No | When `settings` is alternative 2: one of: `local-file`, `more-parts`, `complete`, `cancel` (by `kind`) |
| `settings.thumbnail.filename` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `local-file`: string; min length 1; max length 240; pattern `^[^/\\:\x00-\x1f\x7f]+$`; The name of the file on this computer, with its extension. |
| `settings.thumbnail.byteLength` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `local-file`: integer; greater than 0; The exact file size in bytes (for example from `wc -c < FILE` or `stat`). |
| `settings.thumbnail.sha256` | No | When `settings` is alternative 2 and `settings.thumbnail.kind` is `local-file`: string; pattern `^[0-9a-f]{64}$` |
| `settings.thumbnail.uploadId` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `more-parts`: string; pattern `^upl_[A-Za-z0-9_-]{22}$` |
| `settings.thumbnail.fromPart` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `more-parts`: integer; minimum 1; maximum 10000 |
| `settings.thumbnail.uploadId` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: string; pattern `^upl_[A-Za-z0-9_-]{22}$` |
| `settings.thumbnail.parts` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: array; min items 1; max items 10000 |
| `settings.thumbnail.parts[].partNumber` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: integer; minimum 1; maximum 10000 |
| `settings.thumbnail.parts[].etag` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: string; min length 1; max length 200 |
| `settings.thumbnail.measured` | No | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: object; What the video measures on this computer, read with measureCommand. |
| `settings.thumbnail.measured.durationSeconds` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: number; maximum 1000000; greater than 0; Video duration in seconds (ffprobe format.duration). |
| `settings.thumbnail.measured.frameRate` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: number; maximum 1000; greater than 0; Frames per second (ffprobe avg_frame_rate, for example 30000/1001 = 29.97). |
| `settings.thumbnail.measured.width` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: integer; maximum 100000; greater than 0 |
| `settings.thumbnail.measured.height` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: integer; maximum 100000; greater than 0 |
| `settings.thumbnail.measured.frameRateMode` | No | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: `CFR`, `VFR` |
| `settings.thumbnail.measured.minFrameRate` | No | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: number; maximum 1000; greater than 0 |
| `settings.thumbnail.measured.maxFrameRate` | No | When `settings` is alternative 2 and `settings.thumbnail.kind` is `complete`: number; maximum 1000; greater than 0 |
| `settings.thumbnail.uploadId` | Yes | When `settings` is alternative 2 and `settings.thumbnail.kind` is `cancel`: string; pattern `^upl_[A-Za-z0-9_-]{22}$` |
| `confirmToken` | No | string; pattern `^ct[a-f\d]{32}$`; The confirmToken from this tool's preview result. Omit it to preview; repeat the same input with it to execute. |
## Results and task recovery
Create POST accepts HTTP200/201 data; update PUT requires HTTP200 data. Acknowledgement is followed by full selected-record readback, not treated as completion. Stable selected context and full before content are checked before effects. A metadata-only no-op records done without a write; a supplied thumbnail remains a separate effect.
New dry run returns LEGACY_LINK_PLAN, dryRun:true, noop, changes, keywordAvailability:not-checked-by-dryRun, plannedStages, writesPerformed:false and verification.confirmed:false. It creates no new task or keyword check. Saved jobs use taskType legacy-promotion-link and the original requestId as taskId. Normal results use LEGACY_LINK_COMPLETE or LEGACY_LINK_PENDING with phase, historical, noop, acknowledged and writesPerformed. After done they include promotionLink, revision and shareUrl; created/upload metadata appear when recorded.
writesPerformed is false for no-op/prepared, true after a durable upload receipt or acknowledged write, and null in other pending states, including uploaded preparation with no receipt or acknowledgement. Fresh completion can confirm full stored readback. Historical completion has confirmed:false; storedCompletion is separate. atomicSnapshot, redirectBehaviorVerified, associatedPageVerified and imageAvailabilityVerified remain false.
| Phase | Continuation contract |
| --- | --- |
| prepared | Revalidate context/content or creation quota/inventory; untouched image work after restart requires original apply bytes. |
| grant-sent or upload-sent | Preserve and inspect; attempted grant/upload is never repeated, even after a definite failure. |
| uploaded | A durable receipt or finished no-image preparation permits the untouched write after fresh checks. |
| write-sent | Readback only. A known ID can reconcile; a create without durable returned identity stays LEGACY_CREATE_UNKNOWN, without name/keyword adoption or resend. |
| done | Immutable historical status/resume; confirmed:false. Read again for current state. |
Status does not dispatch effects. Resume may continue untouched stages; sent create/update/delete/grant/upload never repeats. Pending results may contain lastResult, requiredAction and journalPersistence. A changed input under the same requestId returns REQUEST_ID_CONFLICT; malformed saved identity/progress returns TASK_STATE_UNVERIFIED. Preserve the original task instead of forcing a new ID after uncertainty. No automatic recache runs.
Navigation opens the legacy create route until a returned ID is durable, then `/{id}/edit`; update opens edit, and preflight quota/policy failure opens the human plan page. Demo omits live links. No associated page/membership editing or remote cleanup is implied.
## Saved record identity
A missing record differs from an existing malformed record. Stored JSON null,
false, zero or an empty string is rejected with `TASK_STATE_UNVERIFIED` by the
existing record validator. Apply/dry-run/status/resume must not overwrite it or
treat it as a fresh task. Preserve the file for inspection; do not delete/edit it
or replace the request ID to bypass the error. Existing valid task recovery and
genuinely missing-record behavior remain unchanged.