Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>` 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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_` | — |
Expand Down
12 changes: 6 additions & 6 deletions crates/tango/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions crates/tango/src/models/contract_appeal.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

/// Deciding board: `"cbca"` (civilian) or `"asbca"` (defense).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board: Option<String>,

/// 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<Vec<String>>,

/// Where the docket numbers were read from.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docket_source: Option<String>,

/// The docket string exactly as the board published it, before parsing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docket_raw: Option<String>,

/// ISO date the decision issued.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_date: Option<String>,

/// The decision date exactly as the board published it, before parsing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_date_raw: Option<String>,

/// 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<bool>,

/// The contractor bringing the appeal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appellant: Option<String>,

/// The judge who wrote the decision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub judge: Option<String>,

/// Normalized decision kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_type: Option<String>,

/// The decision kind exactly as the board published it, before normalization.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_type_raw: Option<String>,

/// Link to the decision document on the board's own site.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,

/// The board's identifier for the decision document.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,

/// Link to the board listing page the decision was found on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listing_url: Option<String>,

/// 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<i64>,

/// 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<String>,

/// Whether the decision is still on a current board listing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listed: Option<bool>,

/// State of the decision's text extraction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_status: Option<String>,

/// 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<i64>,

/// 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<String>,

/// Forward-compatible bucket for any unrecognized fields the server adds.
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
2 changes: 2 additions & 0 deletions crates/tango/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Loading
Loading