{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/core/canonical-proposal.json",
  "title": "Canonical Proposal",
  "description": "Compact immutable proposal for the AdCP 3.2 lifecycle. commercial_terms is the sole authoritative commercial envelope; narrative fields do not duplicate legacy allocation or creative graphs.",
  "type": "object",
  "properties": {
    "proposal_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 255,
      "x-entity": "proposal"
    },
    "proposal_kind": {
      "type": "string",
      "enum": [
        "new_media_buy",
        "media_buy_update",
        "media_buy_cancellation"
      ]
    },
    "proposal_initiator": {
      "$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/enums/proposal-initiator.json",
      "x-status": "experimental",
      "x-added-in": "3.3.0",
      "description": "Experimental (media_buy.seller_offers). Absent means buyer. A seller proposal is an offer to amend or cancel an accepted media buy: it takes a media_buy_update or media_buy_cancellation kind, status committed (accepted once the buyer accepts) and a proposal_id matching the proposal.offered notification_id pattern. The reason for the offer is not part of the canonical terms: it is the offer_reason on the list_proposals row and the proposal.offered notification. Sellers MUST NOT emit it to callers pinned below AdCP 3.3; no declared pin counts as below 3.3."
    },
    "parent_proposal_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 255,
      "x-entity": "proposal",
      "description": "Immediate predecessor this snapshot was forked from. Every proposal produced by refine_proposals carries it, equal to the request's source proposal_id, so negotiation lineage is reconstructible from proposals alone."
    },
    "media_buy_id": {
      "type": "string",
      "minLength": 1,
      "x-entity": "media_buy"
    },
    "opportunity_id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 255,
      "pattern": "^[A-Za-z0-9_.:-]{1,255}$",
      "x-entity": "opportunity",
      "description": "Buyer planning cycle associated with this proposal. Revisions inherit it; it does not participate in proposal identity."
    },
    "base_media_buy_revision": {
      "type": "integer",
      "minimum": 1
    },
    "proposal_status": {
      "$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/enums/proposal-status.json",
      "description": "draft is indicative and unreserved; committed has firm terms with inventory reserved until expires_at; accepted is the historical snapshot attached to a MediaBuy."
    },
    "accepted_at": {
      "type": "string",
      "format": "date-time"
    },
    "expires_at": {
      "type": "string",
      "format": "date-time",
      "description": "For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline under inventory_hold semantics, or the acceptance-window deadline under terms_only semantics (get_adcp_capabilities media_buy.proposal_refinement.hold_semantics, or hold_policy.available false). For a seller offer it is also the buyer's response deadline."
    },
    "hold_policy": {
      "$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/core/hold-policy.json",
      "x-status": "experimental",
      "x-added-in": "3.3.0",
      "description": "Experimental (media_buy.hold_policy). On a draft, the binding policy for a successful finalize of this draft, derived by the seller from the allocated products when it created the draft: available is false if any allocated product's effective policy is non-holdable, min_duration and max_duration do not exceed the max_duration of any allocated product that declares one, max_total_duration does not exceed that of any allocated product that declares one, extendable is true only if every allocated product is extendable, and rankable is true if any allocated product's effective policy is rankable. Product values are planning hints; the immutable draft governs finalize even if a product's policy changes. On a committed proposal, the seller carries the draft's policy forward unchanged; it governs the hold finalize placed, including extension limits, and each extend_hold successor carries it forward unchanged. A seller that lists media_buy.hold_policy MUST include it on every draft and committed proposal it returns to a caller at 3.3 or later, and MUST omit it for callers below 3.3. When absent, get_adcp_capabilities media_buy.proposal_refinement.hold_semantics applies."
    },
    "hold_placed_at": {
      "type": "string",
      "format": "date-time",
      "x-status": "experimental",
      "x-added-in": "3.3.0",
      "description": "Experimental (media_buy.hold_policy). When the seller placed this committed proposal's hold, which is when it created the committed proposal. Required on a committed proposal whose hold_policy.available is true; forbidden on drafts and when hold_policy.available is false. It MUST be no earlier than when the seller received the finalize request and no later than the synchronous response or, for a submitted finalize, the task's completion. On the committed proposal created by finalize, hold_placed_at plus hold_policy.min_duration MUST NOT be later than expires_at, and expires_at MUST NOT be later than hold_placed_at plus hold_policy.max_duration. An exact retry returns the original hold_placed_at and expires_at; it never restarts the clock. Each extend_hold successor carries hold_placed_at forward unchanged and is bounded by hold_policy.max_total_duration instead of max_duration: its expires_at MUST NOT be later than hold_placed_at plus max_total_duration, so extension never restarts that clock. Omitted, with hold_policy, for callers below 3.3."
    },
    "hold_rank": {
      "type": "integer",
      "minimum": 1,
      "x-status": "experimental",
      "x-added-in": "3.3.0",
      "description": "Experimental (media_buy.hold_ranking). Advisory readback of the buyer's own position in the seller's queue of holds on the inventory this committed proposal holds: 1 is first position, and n means n - 1 other holds are ahead. A hold is ahead when its booking could prevent this one from booking; holds on capacity this hold does not need are not counted. Required on a committed proposal whose hold_policy has available true and rankable true; forbidden on drafts and whenever hold_policy is absent, has available false, or does not have rankable true. When the hold covers inventory at different positions, this is the worst (largest) of them, because acceptance is all or none. It is a point-in-time snapshot taken when the seller created this committed proposal (hold_placed_at for a finalize, the extension for an extend_hold successor). The proposal is immutable, so the value is never updated, an exact retry replays it, and no notification reports a change. A seller MUST NOT move a live hold to a worse position after placing it: no later hold or booking stands ahead of it, and commitments the seller must honor, such as renewal rights, are counted when the hold is placed. The buyer's current position is therefore never worse than the reported one; it improves as holds ahead lapse, are released, or are declined. A buyer reads a newer position from an extend_hold successor, or learns whether it can book by calling accept_proposal. The value discloses only how many holds are ahead: a seller MUST NOT disclose any other buyer's identity or terms through any field, reason, or error, and MUST NOT disclose another hold's expiry except that expected_confirmation_at on a submitted accept_proposal MAY reflect when a hold ahead is due to resolve. It returns hold_rank only on proposals it returns to the buyer that holds them. Sellers emitting it MUST list media_buy.hold_ranking in experimental_features. Omitted, with hold_policy, for callers below 3.3."
    },
    "holds": {
      "type": "array",
      "minItems": 1,
      "x-status": "experimental",
      "x-added-in": "3.3.0",
      "description": "Experimental (media_buy.installment_holds). Readback of the specific installments this committed proposal's hold covers. It describes the one hold that finalize placed, not separate holds: every entry was placed by the same all-or-none finalize, shares hold_placed_at and this proposal's expires_at, and is accepted, extended, superseded, or lapses with the proposal as a whole. No task addresses an entry on its own, and there are no partial holds. Allowed only on a non-draft proposal whose hold_policy has available true; forbidden on drafts and on terms-only commitments. It is readback, not contract: commercial_terms stays authoritative. When present it lists every installment the hold covers, once per product_id and installment_ref; every product_id is a product in commercial_terms.purchases, and every installment is one that product includes, scheduled within that purchase's flight. Inventory that is not a specific installment, such as a run-of-collection product, is not listed. Entries carry no expires_at: this proposal's expires_at is the hold deadline for every entry, and it is already capped at the earliest booking_deadline or change_cutoff of the held installments. Under a rankable hold_policy (media_buy.hold_ranking), every entry carries hold_rank, and this proposal's hold_rank is no better than any entry's; it equals the worst entry's unless unlisted inventory is held at a worse position. The array lists only this proposal's own hold: sellers MUST NOT include or describe other buyers' holds on the same installments. A seller that lists media_buy.installment_holds MUST include holds on every committed proposal it returns to a caller at 3.3 or later that allocates a product declaring specific installments. Each extend_hold successor lists the same entries, at positions no worse than the source's. Omitted, with hold_policy, for callers below 3.3.",
      "items": {
        "type": "object",
        "properties": {
          "product_id": {
            "type": "string",
            "minLength": 1,
            "x-entity": "product",
            "description": "Allocated product, from commercial_terms.purchases, whose inventory in this installment is held."
          },
          "installment_ref": {
            "$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/core/installment-ref.json",
            "description": "The held installment."
          },
          "hold_rank": {
            "type": "integer",
            "minimum": 1,
            "description": "The buyer's own position on this installment, with the same snapshot, never-worse, and privacy rules as the proposal's hold_rank. Present on every entry when the proposal carries hold_rank, and forbidden otherwise."
          }
        },
        "required": [
          "product_id",
          "installment_ref"
        ],
        "additionalProperties": false
      }
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 500
    },
    "description": {
      "type": "string",
      "maxLength": 2000
    },
    "brief_alignment": {
      "type": "string",
      "maxLength": 2000
    },
    "commercial_terms": {
      "$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/media-buy/commercial-terms.json"
    },
    "terms_digest": {
      "type": "string",
      "pattern": "^sha256:[A-Za-z0-9_-]{43}$",
      "description": "Base64url SHA-256 digest of the RFC 8785 JCS serialization of commercial_terms, prefixed with sha256:."
    },
    "insertion_order": {
      "$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/core/insertion-order.json"
    },
    "total_budget_guidance": {
      "type": "object",
      "description": "Optional budget guidance for this proposal — the planning answer to criteria.outcome_target and to open-budget briefs. commercial_terms.total_budget remains the concrete figure the plan is priced at; this band expresses the seller's recommended range around it. When criteria.outcome_target carries cost_per, the cost answer is commercial_terms.bidding.cost_per and this band's currency equals cost_per.currency.",
      "properties": {
        "min": {
          "type": "number",
          "minimum": 0
        },
        "recommended": {
          "type": "number",
          "minimum": 0
        },
        "max": {
          "type": "number",
          "minimum": 0
        },
        "currency": {
          "type": "string",
          "pattern": "^[A-Z]{3}$"
        }
      },
      "required": [
        "currency"
      ],
      "anyOf": [
        {
          "required": [
            "recommended"
          ]
        },
        {
          "required": [
            "min"
          ]
        },
        {
          "required": [
            "max"
          ]
        }
      ],
      "additionalProperties": false
    },
    "forecast": {
      "allOf": [
        {
          "$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.2/core/canonical-delivery-forecast.json"
        }
      ],
      "description": "Aggregate forecasted delivery for the proposal. For outcome_target requests, points carry the goal's metric or event key in metrics, or for a vendor_metric goal in vendor_metric_values[] plus metrics.spend; with cost_per, that is the goal volume planned under the commercial_terms.bidding policy, and currency equals cost_per.currency."
    }
  },
  "required": [
    "proposal_id",
    "proposal_kind",
    "proposal_status",
    "name",
    "commercial_terms",
    "terms_digest"
  ],
  "allOf": [
    {
      "if": {
        "properties": {
          "proposal_initiator": {
            "const": "seller"
          }
        },
        "required": [
          "proposal_initiator"
        ]
      },
      "then": {
        "not": {
          "required": [
            "opportunity_id"
          ]
        },
        "properties": {
          "proposal_id": {
            "pattern": "^[A-Za-z0-9_.:-]{1,255}$"
          },
          "proposal_kind": {
            "enum": [
              "media_buy_update",
              "media_buy_cancellation"
            ]
          },
          "proposal_status": {
            "enum": [
              "committed",
              "accepted"
            ]
          }
        }
      }
    },
    {
      "if": {
        "properties": {
          "proposal_kind": {
            "enum": [
              "media_buy_update",
              "media_buy_cancellation"
            ]
          }
        },
        "required": [
          "proposal_kind"
        ]
      },
      "then": {
        "required": [
          "parent_proposal_id",
          "media_buy_id",
          "base_media_buy_revision"
        ]
      }
    },
    {
      "if": {
        "properties": {
          "proposal_status": {
            "const": "accepted"
          }
        },
        "required": [
          "proposal_status"
        ]
      },
      "then": {
        "required": [
          "media_buy_id",
          "accepted_at"
        ]
      }
    },
    {
      "if": {
        "properties": {
          "proposal_status": {
            "const": "committed"
          }
        },
        "required": [
          "proposal_status"
        ]
      },
      "then": {
        "required": [
          "expires_at"
        ]
      }
    },
    {
      "description": "A committed proposal that holds inventory records when the hold was placed.",
      "if": {
        "properties": {
          "proposal_status": {
            "const": "committed"
          },
          "hold_policy": {
            "properties": {
              "available": {
                "const": true
              }
            },
            "required": [
              "available"
            ]
          }
        },
        "required": [
          "proposal_status",
          "hold_policy"
        ]
      },
      "then": {
        "required": [
          "hold_placed_at"
        ]
      }
    },
    {
      "description": "hold_placed_at describes a placed hold, so it needs a hold_policy that holds and a non-draft proposal.",
      "if": {
        "required": [
          "hold_placed_at"
        ]
      },
      "then": {
        "required": [
          "hold_policy"
        ],
        "properties": {
          "proposal_status": {
            "not": {
              "const": "draft"
            }
          },
          "hold_policy": {
            "properties": {
              "available": {
                "const": true
              }
            }
          }
        }
      }
    },
    {
      "description": "A committed proposal holding a ranked hold reports the buyer's position.",
      "if": {
        "properties": {
          "proposal_status": {
            "const": "committed"
          },
          "hold_policy": {
            "properties": {
              "available": {
                "const": true
              },
              "rankable": {
                "const": true
              }
            },
            "required": [
              "available",
              "rankable"
            ]
          }
        },
        "required": [
          "proposal_status",
          "hold_policy"
        ]
      },
      "then": {
        "required": [
          "hold_rank"
        ]
      }
    },
    {
      "description": "hold_rank describes a placed ranked hold, so it needs a hold_policy with available and rankable true and a non-draft proposal.",
      "if": {
        "required": [
          "hold_rank"
        ]
      },
      "then": {
        "required": [
          "hold_policy"
        ],
        "properties": {
          "proposal_status": {
            "not": {
              "const": "draft"
            }
          },
          "hold_policy": {
            "properties": {
              "available": {
                "const": true
              },
              "rankable": {
                "const": true
              }
            },
            "required": [
              "rankable"
            ]
          }
        }
      }
    },
    {
      "description": "holds describes a placed hold, so it needs a hold_policy that holds and a non-draft proposal.",
      "if": {
        "required": [
          "holds"
        ]
      },
      "then": {
        "required": [
          "hold_policy"
        ],
        "properties": {
          "proposal_status": {
            "not": {
              "const": "draft"
            }
          },
          "hold_policy": {
            "properties": {
              "available": {
                "const": true
              }
            }
          }
        }
      }
    },
    {
      "description": "holds entries carry hold_rank exactly when the proposal does.",
      "if": {
        "required": [
          "hold_rank"
        ]
      },
      "then": {
        "properties": {
          "holds": {
            "items": {
              "required": [
                "hold_rank"
              ]
            }
          }
        }
      },
      "else": {
        "properties": {
          "holds": {
            "items": {
              "not": {
                "required": [
                  "hold_rank"
                ]
              }
            }
          }
        }
      }
    },
    {
      "if": {
        "properties": {
          "proposal_kind": {
            "const": "media_buy_cancellation"
          }
        },
        "required": [
          "proposal_kind"
        ]
      },
      "then": {
        "properties": {
          "commercial_terms": {
            "required": [
              "cancellation_terms"
            ]
          }
        }
      }
    }
  ],
  "additionalProperties": true
}
