From 7162d817050f3c04fdac795d447429ee62447d99 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Sun, 20 Sep 2026 13:01:05 -0500 Subject: [PATCH] feat(contract-appeals): add the contract appeals resource `list_contract_appeals`, `get_contract_appeal` and `iterate_contract_appeals` cover `/api/contract_appeals/`, the Contract Disputes Act decisions of the civilian and defense boards of contract appeals. `ListContractAppealsOptions` carries all ten filters, `ContractAppealRecord` types the 21 response fields, and `SHAPE_CONTRACT_APPEALS_MINIMAL` withholds the Enterprise-gated decision text the way the other paid-leaf shapes do. Tango API 4.26.0. --- CHANGELOG.md | 10 + README.md | 1 + crates/tango/src/lib.rs | 12 +- crates/tango/src/models/contract_appeal.rs | 111 +++++ crates/tango/src/models/mod.rs | 2 + .../tango/src/resources/contract_appeals.rs | 431 ++++++++++++++++++ crates/tango/src/resources/mod.rs | 2 + crates/tango/src/shapes.rs | 10 + docs/API_REFERENCE.md | 21 + docs/WEBHOOKS.md | 1 + 10 files changed, 595 insertions(+), 6 deletions(-) create mode 100644 crates/tango/src/models/contract_appeal.rs create mode 100644 crates/tango/src/resources/contract_appeals.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e48d0..bf7d783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ This project follows [Semantic Versioning](https://semver.org/). #### Added +- **Contract appeals — CBCA and ASBCA decisions** (Tango API 4.26.0). `resources/contract_appeals.rs` adds `list_contract_appeals` / `get_contract_appeal` / `iterate_contract_appeals`, two `bon`-derived options builders (`ListContractAppealsOptions`, `GetContractAppealOptions`), the typed `models::ContractAppealRecord`, and `SHAPE_CONTRACT_APPEALS_MINIMAL`. Every filter the endpoint accepts is a named field: `search`, `board`, `docket`, `appellant`, `judge`, `decision_type`, `decision_date_after` / `_before`, `listed`, `document_id`, plus `ordering` over `decision_date` (the server's default is `-decision_date`), `appellant`, `first_listed_at` and `rank`. + + **These are Contract Disputes Act appeals, not bid protests.** An appeal is a dispute under a contract already awarded, decided by a board; a protest challenges the award itself and stays on `list_protests`. A company can appear in both corpora and nothing joins the two. + + **`decision_text` needs an Enterprise plan, and below it the key is absent rather than null** — so `ContractAppealRecord::decision_text == None` means "not served to this caller", never "this decision has no text". `text_status` and `text_char_count` describe the text at every plan, and `SHAPE_CONTRACT_APPEALS_MINIMAL` deliberately does not name the body, so a list page never pays for text most callers are not served. `minimal_shape_does_not_name_the_paid_decision_text` pins the constant. + + Every field on `ContractAppealRecord` is `Option<...>` because an unshaped list response carries only a core subset — an unrequested field is absent, not null, and `serde(skip_serializing_if)` keeps a round-trip from reintroducing it. `listed` is `Option` on the options builder for the same reason `active` is on SLED: `false` has to reach the server as a filter value rather than collapsing into the default. + - **`naics_code` on `ListProtestsOptions`.** Filters protests by the NAICS code at issue, which only SBA OHA size and NAICS appeals carry; GAO and COFC records never match it. - **`attachments(extracted_text)` — SLED document bodies on the Small plan and above** (Tango API 4.25.1; parity with tango-python, tango-node and tango-go). This SDK returns `Record`, so the leaf needs no type change — what it needed was saying so. Documented on `get_sled_opportunity`, on `SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE` and in `docs/API_REFERENCE.md`: the leaf must be **named** (no `SHAPE_SLED_*` constant includes it, and `attachments(*)` does not carry it, because the API resolves the body only for a caller who asked); the **key is absent rather than null** when the text is not being served; and a **contested document never returns text at any plan**. `suggested_shapes_do_not_name_the_paid_document_body` pins the constants. Searching document text stays ungated on every plan and returns no fragment of it. @@ -34,7 +42,9 @@ This project follows [Semantic Versioning](https://semver.org/). #### Documentation +- New **Contract appeals** section in `docs/API_REFERENCE.md`, and a row in the README method table. Both say what the resource is not: an appeal is a dispute under an awarded contract, so an appeals search is not a substitute for a protests search or the reverse. - New **State & Local — SLED** section in `docs/API_REFERENCE.md` covering all six methods and both defaults that surprise people. +- `docs/WEBHOOKS.md` alert semantics names `contract_appeal` / `alerts.contract_appeal.match` and says it overlaps neither `contract` nor the protest corpus. The SDK models `query_type` and `event_types` as free-form strings, so this is a documentation change rather than a new constant. - `docs/WEBHOOKS.md` alert semantics gained the date-lapse rule and its one exception. An exclusion or DIBBS solicitation reaching its date fires nothing, because open/closed is derived at query time — but `alerts.sled_opportunity.match` **does** fire on a closing, since SLED liveness is a stored column a fifteen-minute sweep writes. ## [0.1.0] — 2026-05-15 diff --git a/README.md b/README.md index 90c93f1..e4654ec 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ The SDK exposes ~75 methods on `Client` covering every endpoint in the sibling S | Forecasts | `list_forecasts` | — | `iterate_forecasts` | | Grants | `list_grants` | — | `iterate_grants` | | Protests | `list_protests` | `get_protest` *(typed: `ProtestRecord`)* | `iterate_protests` | +| Contract appeals | `list_contract_appeals` | `get_contract_appeal` *(typed: `ContractAppealRecord`)* | `iterate_contract_appeals` | | IT Dashboard | `list_itdashboard` | `get_itdashboard` | `iterate_itdashboard` | | NAICS / PSC | `list_naics` / `list_psc` | `get_naics` / `get_psc` | — | | Webhooks (CRUD) | `list_webhook_endpoints` / `list_webhook_alerts` | `get_` / `create_` / `update_` / `delete_` / `test_` | — | diff --git a/crates/tango/src/lib.rs b/crates/tango/src/lib.rs index 2c79466..6435426 100644 --- a/crates/tango/src/lib.rs +++ b/crates/tango/src/lib.rs @@ -119,12 +119,12 @@ pub use error::{Error, ErrorBody, Result}; pub use internal::ListOptions; pub use pagination::{Page, PageStream}; pub use shapes::{ - DEFAULT_BASE_URL, SHAPE_CONTRACTS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, - SHAPE_ENTITIES_MINIMAL, SHAPE_FORECASTS_MINIMAL, SHAPE_GRANTS_MINIMAL, - SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, SHAPE_IDVS_COMPREHENSIVE, SHAPE_IDVS_MINIMAL, - SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, - SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, SHAPE_ORGANIZATIONS_MINIMAL, - SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL, + DEFAULT_BASE_URL, SHAPE_CONTRACTS_MINIMAL, SHAPE_CONTRACT_APPEALS_MINIMAL, + SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_MINIMAL, SHAPE_FORECASTS_MINIMAL, + SHAPE_GRANTS_MINIMAL, SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, SHAPE_IDVS_COMPREHENSIVE, + SHAPE_IDVS_MINIMAL, SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, + SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, + SHAPE_ORGANIZATIONS_MINIMAL, SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL, SHAPE_SLED_FORECASTS_COMPREHENSIVE, SHAPE_SLED_FORECASTS_MINIMAL, SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE, SHAPE_SLED_OPPORTUNITIES_MINIMAL, SHAPE_SLED_REVISIONS_MINIMAL, SHAPE_SUBAWARDS_MINIMAL, SHAPE_VEHICLES_COMPREHENSIVE, diff --git a/crates/tango/src/models/contract_appeal.rs b/crates/tango/src/models/contract_appeal.rs new file mode 100644 index 0000000..21161a5 --- /dev/null +++ b/crates/tango/src/models/contract_appeal.rs @@ -0,0 +1,111 @@ +//! `ContractAppealRecord` — typed response from `GET /api/contract_appeals/{uuid}/`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// A Contract Disputes Act appeal decision from a board of contract appeals. +/// +/// Returned by [`Client::get_contract_appeal`](crate::Client::get_contract_appeal). +/// These are contract-dispute decisions — a claim between the government and a contractor already under contract — and not bid protests; those are [`ProtestRecord`](crate::models::ProtestRecord). +/// +/// Every field is optional because the response schema follows the requested `shape`: an unshaped list response carries only a core subset, so a field the caller did not ask for is ABSENT rather than null. +/// Unknown server-side fields fall through to [`extra`](Self::extra). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct ContractAppealRecord { + /// Tango identifier for the decision, and the path segment + /// [`Client::get_contract_appeal`](crate::Client::get_contract_appeal) takes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uuid: Option, + + /// Deciding board: `"cbca"` (civilian) or `"asbca"` (defense). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub board: Option, + + /// Every docket number the decision resolves. + /// A consolidated decision carries several, which is why this is a list and the `docket` filter matches any member. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docket_numbers: Option>, + + /// Where the docket numbers were read from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docket_source: Option, + + /// The docket string exactly as the board published it, before parsing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docket_raw: Option, + + /// ISO date the decision issued. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_date: Option, + + /// The decision date exactly as the board published it, before parsing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_date_raw: Option, + + /// Whether [`decision_date`](Self::decision_date) was reconstructed rather than parsed straight from + /// [`decision_date_raw`](Self::decision_date_raw). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_date_repaired: Option, + + /// The contractor bringing the appeal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub appellant: Option, + + /// The judge who wrote the decision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub judge: Option, + + /// Normalized decision kind. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_type: Option, + + /// The decision kind exactly as the board published it, before normalization. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_type_raw: Option, + + /// Link to the decision document on the board's own site. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// The board's identifier for the decision document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub document_id: Option, + + /// Link to the board listing page the decision was found on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listing_url: Option, + + /// Year of the board listing page. + /// A board files a decision under the year it publishes the listing, which is not always the year it decided. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listing_year: Option, + + /// Timestamp Tango first saw the decision on a listing page. + /// The polling primitive: everything new since your last call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_listed_at: Option, + + /// Whether the decision is still on a current board listing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listed: Option, + + /// State of the decision's text extraction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_status: Option, + + /// Character count of the extracted decision text. + /// Readable at every plan, so a caller can size a decision without being entitled to read it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_char_count: Option, + + /// The full text of the decision, on an Enterprise plan. + /// Below Enterprise the key is ABSENT rather than null, so `None` here means "not served" and never "the decision has no text" — [`text_status`](Self::text_status) and + /// [`text_char_count`](Self::text_char_count) answer that question at every plan. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_text: Option, + + /// Forward-compatible bucket for any unrecognized fields the server adds. + #[serde(flatten)] + pub extra: HashMap, +} diff --git a/crates/tango/src/models/mod.rs b/crates/tango/src/models/mod.rs index 4a95a8b..aab3203 100644 --- a/crates/tango/src/models/mod.rs +++ b/crates/tango/src/models/mod.rs @@ -13,12 +13,14 @@ //! through `serde_json::to_value` will re-emit those extras. pub mod agency; +pub mod contract_appeal; pub mod protest; pub mod resolve; pub mod validate; pub mod webhook; pub use agency::AgencyRecord; +pub use contract_appeal::ContractAppealRecord; pub use protest::ProtestRecord; pub use resolve::{ResolveCandidate, ResolveInput, ResolveResult, ResolveTargetType}; pub use validate::{ValidateInput, ValidateInputType, ValidateResult}; diff --git a/crates/tango/src/resources/contract_appeals.rs b/crates/tango/src/resources/contract_appeals.rs new file mode 100644 index 0000000..62d4610 --- /dev/null +++ b/crates/tango/src/resources/contract_appeals.rs @@ -0,0 +1,431 @@ +//! `GET /api/contract_appeals/` — Contract Disputes Act appeal decisions from the Civilian Board of Contract Appeals (CBCA) and the Armed Services Board of Contract Appeals (ASBCA). +//! +//! These are disputes under an existing contract — claims, terminations, delays, defective specifications — decided by a board. +//! They are NOT bid protests, which challenge an award before performance and live on [`Client::list_protests`](crate::Client::list_protests). +//! A company can appear in both corpora, and nothing joins the two. + +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool}; +use crate::models::ContractAppealRecord; +use crate::pagination::{FetchFn, Page, PageStream}; +use crate::resources::agencies::urlencoding; +use crate::Record; +use bon::Builder; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Options for [`Client::list_contract_appeals`] and [`Client::iterate_contract_appeals`]. +/// +/// The `_after` / `_before` date suffixes mirror the Python SDK's naming. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListContractAppealsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use + /// [`SHAPE_CONTRACT_APPEALS_MINIMAL`](crate::SHAPE_CONTRACT_APPEALS_MINIMAL) or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// Full-text search over the decision. + /// Required for `ordering = "rank"`, which is otherwise meaningless. + #[builder(into)] + pub search: Option, + /// Deciding board: `"cbca"` (civilian) or `"asbca"` (defense). + #[builder(into)] + pub board: Option, + /// Docket number. + /// A consolidated decision carries several, and this matches any one of them. + #[builder(into)] + pub docket: Option, + /// The contractor bringing the appeal. + #[builder(into)] + pub appellant: Option, + /// The judge who wrote the decision. + #[builder(into)] + pub judge: Option, + /// Normalized decision kind. + #[builder(into)] + pub decision_type: Option, + /// Whether the decision is still on a current board listing. + /// `Option` so that `false` is a real filter value rather than an absent one. + #[builder(into)] + pub listed: Option, + /// The board's identifier for the decision document. + #[builder(into)] + pub document_id: Option, + + /// Lower bound on `decision_date` (ISO `YYYY-MM-DD`, inclusive). + #[builder(into)] + pub decision_date_after: Option, + /// Upper bound on `decision_date` (inclusive). + #[builder(into)] + pub decision_date_before: Option, + + /// One of `decision_date`, `appellant`, `first_listed_at`, `rank`, each optionally `-` prefixed. + /// The server defaults to `-decision_date`. `rank` requires a non-empty [`search`](Self::search). + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListContractAppealsOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + apply_pagination( + &mut q, + self.page, + self.limit, + self.cursor.as_deref(), + self.shape.as_deref(), + self.flat, + self.flat_lists, + ); + push_opt(&mut q, "search", self.search.as_deref()); + push_opt(&mut q, "board", self.board.as_deref()); + push_opt(&mut q, "docket", self.docket.as_deref()); + push_opt(&mut q, "appellant", self.appellant.as_deref()); + push_opt(&mut q, "judge", self.judge.as_deref()); + push_opt(&mut q, "decision_type", self.decision_type.as_deref()); + push_opt_bool(&mut q, "listed", self.listed); + push_opt(&mut q, "document_id", self.document_id.as_deref()); + push_opt( + &mut q, + "decision_date_after", + self.decision_date_after.as_deref(), + ); + push_opt( + &mut q, + "decision_date_before", + self.decision_date_before.as_deref(), + ); + push_opt(&mut q, "ordering", self.ordering.as_deref()); + for (k, v) in &self.extra { + if !v.is_empty() { + q.push((k.clone(), v.clone())); + } + } + q + } +} + +/// Options for [`Client::get_contract_appeal`]. +/// The detail endpoint returns a typed [`ContractAppealRecord`]; `shape` lets callers override the server's default. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct GetContractAppealOptions { + /// Shape selector. When empty, the server returns its default detail shape. + #[builder(into)] + pub shape: Option, + /// Flatten nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When `flat=true`, also flatten list-valued nested fields. + #[builder(default)] + pub flat_lists: bool, +} + +impl GetContractAppealOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + push_opt(&mut q, "shape", self.shape.as_deref()); + if self.flat { + q.push(("flat".into(), "true".into())); + } + if self.flat_lists { + q.push(("flat_lists".into(), "true".into())); + } + q + } +} + +impl Client { + /// `GET /api/contract_appeals/` — one page of board-of-contract-appeals decisions. + pub async fn list_contract_appeals( + &self, + opts: ListContractAppealsOptions, + ) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/contract_appeals/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/contract_appeals/{uuid}/` — fetch a single decision by UUID. + /// + /// Returns a typed [`ContractAppealRecord`]; forward-compatible server fields land in + /// [`ContractAppealRecord::extra`]. + /// + /// `decision_text` — the full text of the decision — needs an Enterprise plan, and below that its key is ABSENT rather than null. + /// So `record.decision_text == None` means "not served to this caller", never "this decision has no text"; `text_status` and `text_char_count` answer that at every plan. + pub async fn get_contract_appeal( + &self, + uuid: &str, + opts: Option, + ) -> Result { + if uuid.is_empty() { + return Err(Error::Validation { + message: "get_contract_appeal: uuid is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/contract_appeals/{}/", urlencoding(uuid)); + self.get_json::(&path, &q).await + } + + /// Stream every contract-appeal decision matching `opts`. + pub fn iterate_contract_appeals(&self, opts: ListContractAppealsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_contract_appeals(next).await }) + }); + PageStream::new(self.clone(), fetch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_q(q: &[(String, String)], k: &str) -> Option { + q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()) + } + + #[test] + fn list_contract_appeals_all_filters_emit() { + let opts = ListContractAppealsOptions::builder() + .search("differing site conditions") + .board("cbca") + .docket("CBCA 1234") + .appellant("Acme Construction") + .judge("Smith") + .decision_type("decision") + .listed(true) + .document_id("doc-9912") + .decision_date_after("2024-01-01") + .decision_date_before("2024-12-31") + .ordering("-decision_date") + .build(); + let q = opts.to_query(); + assert_eq!( + get_q(&q, "search").as_deref(), + Some("differing site conditions") + ); + assert_eq!(get_q(&q, "board").as_deref(), Some("cbca")); + assert_eq!(get_q(&q, "docket").as_deref(), Some("CBCA 1234")); + assert_eq!(get_q(&q, "appellant").as_deref(), Some("Acme Construction")); + assert_eq!(get_q(&q, "judge").as_deref(), Some("Smith")); + assert_eq!(get_q(&q, "decision_type").as_deref(), Some("decision")); + assert_eq!(get_q(&q, "listed").as_deref(), Some("true")); + assert_eq!(get_q(&q, "document_id").as_deref(), Some("doc-9912")); + assert_eq!( + get_q(&q, "decision_date_after").as_deref(), + Some("2024-01-01") + ); + assert_eq!( + get_q(&q, "decision_date_before").as_deref(), + Some("2024-12-31") + ); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("-decision_date")); + } + + #[test] + fn list_contract_appeals_listed_false_is_a_filter_not_an_absence() { + let opts = ListContractAppealsOptions::builder().listed(false).build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "listed").as_deref(), Some("false")); + } + + #[test] + fn list_contract_appeals_zero_value_omitted() { + let opts = ListContractAppealsOptions::builder().build(); + let q = opts.to_query(); + assert!(q.is_empty(), "expected empty query, got {q:?}"); + } + + #[test] + fn list_contract_appeals_cursor_wins_over_page() { + let opts = ListContractAppealsOptions::builder() + .page(2u32) + .cursor("xyz".to_string()) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz")); + assert_eq!(get_q(&q, "page"), None); + } + + #[test] + fn list_contract_appeals_shape_emits() { + let opts = ListContractAppealsOptions::builder() + .shape(crate::SHAPE_CONTRACT_APPEALS_MINIMAL) + .flat(true) + .build(); + let q = opts.to_query(); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_CONTRACT_APPEALS_MINIMAL) + ); + assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); + } + + #[test] + fn list_contract_appeals_extra_emits() { + let mut extra = BTreeMap::new(); + extra.insert("custom_x".to_string(), "xv".to_string()); + let opts = ListContractAppealsOptions::builder().extra(extra).build(); + let q = opts.to_query(); + assert!(q.contains(&("custom_x".into(), "xv".into()))); + } + + #[test] + fn minimal_shape_does_not_name_the_paid_decision_text() { + assert!(!crate::SHAPE_CONTRACT_APPEALS_MINIMAL.contains("decision_text")); + } + + #[test] + fn get_contract_appeal_options_emit() { + let opts = GetContractAppealOptions::builder() + .shape("uuid,decision_text") + .flat(true) + .flat_lists(true) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "shape").as_deref(), Some("uuid,decision_text")); + assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); + assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true")); + } + + #[test] + fn contract_appeal_record_decodes_from_sample_json() { + let value = serde_json::json!({ + "uuid": "3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55", + "board": "cbca", + "docket_numbers": ["CBCA 1234", "CBCA 1235"], + "docket_source": "listing", + "docket_raw": "CBCA 1234, 1235", + "decision_date": "2024-04-20", + "decision_date_raw": "April 20, 2024", + "decision_date_repaired": false, + "appellant": "Acme Construction", + "judge": "Smith", + "decision_type": "decision", + "decision_type_raw": "DECISION", + "url": "https://example.gov/decisions/1234.pdf", + "document_id": "doc-9912", + "listing_url": "https://example.gov/decisions/2024", + "listing_year": 2024, + "first_listed_at": "2024-04-21T06:15:00Z", + "listed": true, + "text_status": "extracted", + "text_char_count": 48213, + "decision_text": "The appeal is sustained.", + "future_field": "still here" + }); + let rec: ContractAppealRecord = serde_json::from_value(value).expect("decode"); + assert_eq!( + rec.uuid.as_deref(), + Some("3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55") + ); + assert_eq!(rec.board.as_deref(), Some("cbca")); + assert_eq!( + rec.docket_numbers.as_deref(), + Some(["CBCA 1234".to_string(), "CBCA 1235".to_string()].as_slice()) + ); + assert_eq!(rec.docket_source.as_deref(), Some("listing")); + assert_eq!(rec.docket_raw.as_deref(), Some("CBCA 1234, 1235")); + assert_eq!(rec.decision_date.as_deref(), Some("2024-04-20")); + assert_eq!(rec.decision_date_raw.as_deref(), Some("April 20, 2024")); + assert_eq!(rec.decision_date_repaired, Some(false)); + assert_eq!(rec.appellant.as_deref(), Some("Acme Construction")); + assert_eq!(rec.judge.as_deref(), Some("Smith")); + assert_eq!(rec.decision_type.as_deref(), Some("decision")); + assert_eq!(rec.decision_type_raw.as_deref(), Some("DECISION")); + assert_eq!( + rec.url.as_deref(), + Some("https://example.gov/decisions/1234.pdf") + ); + assert_eq!(rec.document_id.as_deref(), Some("doc-9912")); + assert_eq!( + rec.listing_url.as_deref(), + Some("https://example.gov/decisions/2024") + ); + assert_eq!(rec.listing_year, Some(2024)); + assert_eq!(rec.first_listed_at.as_deref(), Some("2024-04-21T06:15:00Z")); + assert_eq!(rec.listed, Some(true)); + assert_eq!(rec.text_status.as_deref(), Some("extracted")); + assert_eq!(rec.text_char_count, Some(48213)); + assert_eq!( + rec.decision_text.as_deref(), + Some("The appeal is sustained.") + ); + // Unknown / not-first-classed fields land in `extra` via #[serde(flatten)]. + assert_eq!( + rec.extra.get("future_field").and_then(|v| v.as_str()), + Some("still here") + ); + } + + #[test] + fn contract_appeal_record_decodes_without_decision_text() { + let value = serde_json::json!({ + "uuid": "3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55", + "board": "asbca", + "decision_date": "2024-04-20", + "appellant": "Acme Construction", + "text_status": "extracted", + "text_char_count": 48213 + }); + let rec: ContractAppealRecord = serde_json::from_value(value).expect("decode"); + // Below Enterprise the key is absent, not null — and the record still reports that text exists. + assert_eq!(rec.decision_text, None); + assert!(!rec.extra.contains_key("decision_text")); + assert_eq!(rec.text_status.as_deref(), Some("extracted")); + assert_eq!(rec.text_char_count, Some(48213)); + assert_eq!(rec.docket_numbers, None); + } + + #[test] + fn contract_appeal_record_round_trips_without_reintroducing_absent_keys() { + let rec = ContractAppealRecord { + uuid: Some("abc".into()), + board: Some("cbca".into()), + ..Default::default() + }; + let value = serde_json::to_value(&rec).expect("serialize"); + let obj = value.as_object().expect("object"); + assert_eq!(obj.len(), 2); + assert!(!obj.contains_key("decision_text")); + } + + #[tokio::test] + async fn get_contract_appeal_validates_empty_uuid() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_contract_appeal("", None).await.unwrap_err(); + match err { + Error::Validation { message, .. } => { + assert!(message.contains("uuid is required")); + } + other => panic!("expected Validation, got {other:?}"), + } + } +} diff --git a/crates/tango/src/resources/mod.rs b/crates/tango/src/resources/mod.rs index 62755df..fc7d863 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -2,6 +2,7 @@ //! methods on [`Client`](crate::Client) and exports its `*Options` builders. pub(crate) mod agencies; +pub(crate) mod contract_appeals; pub(crate) mod contracts; pub(crate) mod entities; pub(crate) mod entity_subresources; @@ -27,6 +28,7 @@ pub use agencies::{ AgencyContractsOptions, GetAgencyOptions, ListAgenciesOptions, ListAgencyAwardingContractsOptions, ListAgencyFundingContractsOptions, }; +pub use contract_appeals::{GetContractAppealOptions, ListContractAppealsOptions}; pub use contracts::ListContractsOptions; pub use entities::{GetEntityOptions, ListEntitiesOptions}; pub use entity_subresources::EntitySubresourceOptions; diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index 08a6d55..c796792 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -41,6 +41,16 @@ pub const SHAPE_NOTICES_MINIMAL: &str = "notice_id,title,solicitation_number,pos pub const SHAPE_PROTESTS_MINIMAL: &str = "case_id,case_number,title,source_system,outcome,filed_date"; +/// Suggested list shape for +/// [`Client::list_contract_appeals`](crate::Client::list_contract_appeals). +/// +/// Deliberately does not name `decision_text`. The decision body needs an Enterprise plan, so a suggested shape carrying it would make every list page pay for text most callers are not served. +/// Name it explicitly when you are entitled to it; `text_status` and `text_char_count` describe the text at every plan. +pub const SHAPE_CONTRACT_APPEALS_MINIMAL: &str = concat!( + "uuid,board,docket_numbers,decision_date,appellant,judge,", + "decision_type,url,listed", +); + /// Default shape for [`Client::list_grants`](crate::Client::list_grants). pub const SHAPE_GRANTS_MINIMAL: &str = "grant_id,opportunity_number,title,status(*),agency_code"; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 04d7f96..b1bbe39 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -128,6 +128,27 @@ Records come from GAO, the U.S. Court of Federal Claims and the SBA Office of He Options: `ListProtestsOptions`, `GetProtestOptions`. No `ordering` (server rejects it for this resource). +### Contract appeals (`contract_appeals.rs`) + +Contract Disputes Act appeal decisions from the Civilian Board of Contract Appeals (CBCA) and the Armed Services Board of Contract Appeals (ASBCA). + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_contract_appeals(opts)` / `iterate_contract_appeals(opts)` | `GET /api/contract_appeals/` | `Page` / `PageStream` | +| `get_contract_appeal(uuid, opts)` | `GET /api/contract_appeals/{uuid}/` | **`ContractAppealRecord`** (typed) | + +Options: `ListContractAppealsOptions`, `GetContractAppealOptions`. Shape: `SHAPE_CONTRACT_APPEALS_MINIMAL`. + +Filters: `search`, `board` (`cbca` / `asbca`), `docket`, `appellant`, `judge`, `decision_type`, `decision_date_after` / `_before`, `listed`, `document_id`. Ordering: `decision_date` (the server's default is `-decision_date`), `appellant`, `first_listed_at`, `rank` — and `rank` is only meaningful alongside a non-empty `search`. + +**These are not bid protests.** An appeal is a dispute under a contract already awarded — a claim, a termination, a delay, a defective specification — decided by a board rather than by GAO or the Court of Federal Claims. A company can appear in both corpora and nothing joins the two, so an appeals search is not a substitute for a [protests](#protests-protestsrs) search or the reverse. + +**One decision can resolve several dockets.** `docket_numbers` is a list for that reason, and the `docket` filter matches any member of it. `docket_raw`, `decision_date_raw` and `decision_type_raw` are the board's own strings, kept beside the parsed values; `decision_date_repaired` flags a date reconstructed rather than parsed straight through. + +**`listed` is shelf presence, not validity.** It says the decision is still on a current board listing page. A board rotating its listings does not vacate the decisions that fall off them. + +**The decision body is `decision_text`, on an Enterprise plan.** Below Enterprise the key is **absent rather than null**, so `None` means "not served to this caller" and never "this decision has no text" — `text_status` and `text_char_count` describe the text at every plan. `SHAPE_CONTRACT_APPEALS_MINIMAL` deliberately does not name it, so a list page never pays for text most callers are not served. + ### State & Local — SLED (`sled.rs`) — **Beta** State, local and education procurement: solicitations that never appear on SAM.gov because they were never federal. Coverage is partial and grows one jurisdiction at a time. diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 7c7aa53..ce86107 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -202,6 +202,7 @@ Client-side validation rejects empty `name` or `callback_url` on `create_webhook - `filters` must be a non-empty JSON object. Client-side validation rejects `null` or `{}`. - `endpoint` is required when the account has multiple endpoints; single-endpoint accounts can omit it (the server auto-resolves). - `query_type` and `filters` are **read-only after creation**; `update_webhook_alert` only allows `name`, `frequency`, `cron_expression`, `is_active`. +- **`contract_appeal` is its own query type**, emitting `alerts.contract_appeal.match`. It overlaps neither `contract` (an award) nor the protest corpus (a challenge to an award): a board decision is a dispute under a contract already awarded, so an alert on one of the three never stands in for another. - **A record merely reaching its date fires nothing — except `sled_opportunity`.** An exclusion passing its termination date, or a DIBBS solicitation passing its close date, emits no event: open/closed and in-force are derived at query time, so no stored row changes. `sled_opportunity` is the one exception — a state solicitation's liveness is a stored `status` column Tango recomputes every fifteen minutes rather than deriving per request, so a deadline passing **is** a write and `alerts.sled_opportunity.match` can follow it. There is no `sled_forecast` query type (a forecast has no deadline, so nothing transitions), and SLED revisions and attachments are not separately alertable: subscribe to `sled_opportunity` and filter on `change_seen_after` or `revision_kind`. ### Example: create and test an endpoint