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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Contract Disputes Act appeals** (Tango API 4.26.0). Two methods over the new `/api/contract_appeals/` resource: `list_contract_appeals()` and `get_contract_appeal()`, plus a `ContractAppeal` schema and the `CONTRACT_APPEALS_MINIMAL` / `CONTRACT_APPEALS_COMPREHENSIVE` defaults. These are decisions of the Civilian Board of Contract Appeals (CBCA) and the Armed Services Board of Contract Appeals (ASBCA) — appeals of a contracting officer's final decision under an existing contract, which is a different dispute from a bid protest and shares no identifiers with `list_protests()`. All ten of the API's filters are explicit named parameters.

Three behaviors are documented on the methods and the model, because each is otherwise a support question. **`decision_text` needs an Enterprise plan, and below that tier the key is absent rather than null** — read it with `.get()`. **Searching that text is free at every plan**: `search=` matches inside the decision body and returns no fragment of it, so buying the body does not change what you can find. And **`docket` accepts a docket as it is cited** — a leading `CBCA` / `ASBCA` and `No.` / `Nos.` are ignored, while commas stay part of a docket (`7092-C(6682, 6765, 6767)`) rather than separating two.

Neither default shape names `decision_text`: the body runs to roughly 100K characters, so putting it in a default would make every fetch ask for something most callers cannot read. `ordering="rank"` requires a non-empty `search`, matching the API.
- **`alerts.contract_appeal.match` is an alertable event type.** `create_webhook_alert(query_type="contract_appeal", ...)` works. That method's `query_type` docstring now names the full current set and points at `list_webhook_event_types()` as the authority, since a list written into a docstring can only go stale.

### Fixed
- **`attachments(doc_role, doc_role_alt)` on opportunities and notices no longer raises `ShapeValidationError`.** The Tango API serves both fields on a Pro plan or above, but the SDK rejected them client-side after the request had already been made. `doc_role` says what an attachment is for (`requirement`, `instructions`, `pricing`, `terms`, `reference` or `unknown`) and `doc_role_alt` is a runner-up role that is usually null. Both must be named in the shape, since `attachments(*)` does not include them, and an attachment that has not been classified omits both keys, so read them with `.get()`.
- **The shape overlay generator no longer lets one resource's view of a model hide fields from another's.** An opportunity is reachable both from `list_opportunities()` and as the `opportunity` expand on vehicles, and the vehicles embed exposes fewer attachment fields. Whichever tree was generated last replaced the other, so the opportunity schema lost fields its own endpoint serves. The generator now adds the missing fields instead of replacing the schema, and keeps every existing field's type as it was.
- **SLED solicitations accept `delisted_at` and `meta(jurisdiction_declared)`.** Both were rejected client-side even though the API returns them. `delisted_at` is when a portal dropped a solicitation from its listing before its deadline, which is what `status_reason="delisted"` means. `meta.jurisdiction_declared` says whether the source stated the jurisdiction level itself or Tango classified it from the issuer's name. `delisted_at` is now in both `SLED_OPPORTUNITIES_MINIMAL` and `SLED_OPPORTUNITIES_COMPREHENSIVE`, matching the API's own list default.

### Changed
- **SLED response values now carry the types the API returns.** The SDK's SLED schema had guessed several types from field names. `posted_date`, `response_deadline`, `response_deadline_original` and `bid_opening_date` on `SledOpportunity` now parse to timezone-aware `datetime` values (before, `posted_date` and `bid_opening_date` were truncated to a `date` and the two deadlines stayed strings), as does `estimated_advertisement_date` on `SledForecast`. `estimated_value(min, max)` on forecasts now parses to `Decimal`. `category_codes` is typed as a list of `{scheme, code}` objects, `attachments` and `revisions` as lists, `meta(last_change_source_declared)` and `revisions(source_declared)` as booleans, and `attachments(size_bytes, pages)` and `revisions(sequence)` as integers. If you compared any of the date fields as strings, compare them as `datetime` values instead.
- Re-vendored `contracts/filter_shape_contract.json` (Tango API 5.1.0) and regenerated `tango/shapes/generated_overlay.py`. The two resources the SDK does not wrap yet, `contract_appeals` and `ebuy/requests`, are recorded in `contracts/shape_coverage_baseline.json` as known gaps.
- Re-vendored `contracts/filter_shape_contract.json` (Tango API 5.1.0) and regenerated `tango/shapes/generated_overlay.py`. The one resource the SDK does not wrap yet, `ebuy/requests`, is recorded in `contracts/shape_coverage_baseline.json` as a known gap.
- The `ContractAppeal` shape schema is hand-curated in `tango/shapes/explicit_schemas.py` rather than left to the generated overlay. The generator falls back to name heuristics for a resource the live-API type probe has not sampled yet, and those heuristics get four of this resource's fields wrong: `docket_numbers` is a list, `listed` and `decision_date_repaired` are booleans, and `listing_year` is an integer.

### Documentation
- `list_protests()` and `docs/API_REFERENCE.md` now describe all three protest venues: GAO, the U.S. Court of Federal Claims (COFC) and the SBA Office of Hearings and Appeals (SBA OHA). `naics_code` is documented as the NAICS code at issue in an SBA OHA size or NAICS appeal, which GAO and COFC protests never carry, rather than the solicitation's code. `case_number` examples now cover each venue's format, and `naics_code` is listed among the filter parameters.
- `docs/API_REFERENCE.md` documents the document-role fields on opportunity and notice attachments, and `delisted_at` and `meta.jurisdiction_declared` on SLED solicitations.
- New **Contract Appeals** section in `docs/API_REFERENCE.md`, leading with what separates an appeal from a protest, plus the two new `ShapeConfig` constants in the shapes table.
- Added `alerts.contract_appeal.match` to the event-type listing in `docs/WEBHOOKS.md`.

## [1.6.0] - 2026-09-10

Expand Down
3 changes: 1 addition & 2 deletions contracts/shape_coverage_baseline.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
{
"description": "Known reverse shape-coverage gaps (Tango exposes, SDK schema lacks), accepted as a tracked backlog. check_shape_coverage.py fails only on gaps NOT listed here. Burn down and regenerate with --update-baseline.",
"count": 16,
"count": 15,
"known_gaps": [
"unmapped_resource|agencies_contracts_awarding|(root)|None",
"unmapped_resource|agencies_contracts_funding|(root)|None",
"unmapped_resource|contract_appeals|(root)|None",
"unmapped_resource|contracts_subawards|(root)|None",
"unmapped_resource|ebuy/requests|(root)|None",
"unmapped_resource|entities_contracts|(root)|None",
Expand Down
82 changes: 82 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Complete reference for all Tango Python SDK methods and functionality.
- [Grants](#grants)
- [GSA eLibrary Contracts](#gsa-elibrary-contracts)
- [Protests](#protests)
- [Contract Appeals](#contract-appeals)
- [Budget](#budget)
- [Business Types](#business-types)
- [NAICS](#naics)
Expand Down Expand Up @@ -1235,6 +1236,85 @@ protest = client.get_protest(

---

## 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 not bid protests.** A protest challenges an award or a solicitation before performance; an appeal here challenges a contracting officer's final decision under an existing contract — a claim, a termination, a differing-site-conditions dispute. Protests live at [Protests](#protests) and share no identifiers with this resource.

One row is one decision as the board's own listing publishes it, so several fields describe the listing rather than the dispute. `listed` goes false once the board's newest listing stops carrying the decision; the row is kept, never dropped.

### list_contract_appeals()

List appeal decisions with filtering and shaping.

```python
appeals = client.list_contract_appeals(
page=1,
limit=25,
shape=ShapeConfig.CONTRACT_APPEALS_MINIMAL,
# Filter parameters (all optional)
board=None,
docket=None,
appellant=None,
judge=None,
decision_type=None,
decision_date_after=None,
decision_date_before=None,
listed=None,
document_id=None,
search=None,
ordering=None,
)
```

**Filter Parameters:**
- `board` - `"cbca"` or `"asbca"`; OR both with `"cbca|asbca"`
- `docket` - Exact docket match, e.g. `"3288-R"`, `"59116"` or `"7092-C(6682, 6765, 6767)"`. A leading `CBCA` / `ASBCA` and `No.` / `Nos.` are ignored, so a docket can be pasted as it is cited. Commas are part of a docket, never a separator; OR several with `|`
- `appellant` - Case-insensitive substring match on the appellant's name (min 2 characters)
- `judge` - Case-insensitive exact match on the judge as the listing names them
- `decision_type` - CBCA only: `"Decision"`, `"Dismissal"`, `"Order"`, `"Full Board Order"`, or the listing's own text. ASBCA rows carry no type
- `decision_date_after` / `decision_date_before` - Decision date range (`YYYY-MM-DD`)
- `listed` - Whether the board's newest listing still carries the decision
- `document_id` - The board's own document id
- `search` - Ranked full-text search over the appellant and the full decision text (min 2 characters); wrap in double quotes for a phrase
- `ordering` - `decision_date` (the default, as `-decision_date`), `appellant`, `first_listed_at` or `rank`. `rank` requires a non-empty `search`

**Returns:** [PaginatedResponse](#paginatedresponse) with appeal-decision dictionaries

**Example:**
```python
appeals = client.list_contract_appeals(
board="asbca",
search="differing site conditions",
decision_date_after="2025-01-01",
ordering="rank",
limit=25,
)

for appeal in appeals.results:
dockets = ", ".join(appeal["docket_numbers"])
print(f"{appeal['board'].upper()} {dockets}: {appeal['appellant']} ({appeal['decision_date']})")
```

### get_contract_appeal()

Get a single decision by `uuid`.

```python
appeal = client.get_contract_appeal(
"DECISION_UUID",
shape=ShapeConfig.CONTRACT_APPEALS_COMPREHENSIVE,
)
```

**Notes:**
- `docket_numbers` is a list — the dockets with the board prefix stripped (`3288-R`, `59116`), and a consolidated appeal carries several. `docket_raw` keeps the listing's own text, and `docket_source` says where the dockets came from.
- **Reading `decision_text` requires an Enterprise plan, and below that tier the key is absent rather than null** — read it with `.get()`, not `[...]`. Neither default shape names it: the body runs to roughly 100K characters, so asking for it on every row is rarely what you want.
- **Searching that text is free at every plan.** `search=` matches inside the decision body and returns no fragment of it, so buying the body does not change what you can find.

---

## State & Local (SLED)

State, local and education procurement — solicitations that never appear on SAM.gov because they were never federal. **Beta**: coverage is partial and grows one jurisdiction at a time.
Expand Down Expand Up @@ -2229,6 +2309,8 @@ entity = client.get_entity("UEI_KEY", shape=ShapeConfig.ENTITIES_COMPREHENSIVE)
| `SUBAWARDS_MINIMAL` | `list_subawards` | award_key, prime_recipient(uei,display_name), subaward_recipient(uei,display_name) |
| `GSA_ELIBRARY_CONTRACTS_MINIMAL` | `list_gsa_elibrary_contracts` | uuid, contract_number, schedule, recipient(display_name,uei), idv(key,award_date) |
| `PROTESTS_MINIMAL` | `list_protests` | case_id, case_number, title, source_system, outcome, filed_date |
| `CONTRACT_APPEALS_MINIMAL` | `list_contract_appeals` | uuid, board, docket_numbers, decision_date, appellant, judge, decision_type, url |
| `CONTRACT_APPEALS_COMPREHENSIVE` | `get_contract_appeal` | The list fields plus docket_raw, docket_source, decision_date_repaired, decision_type_raw, listing_year, first_listed_at, listed, text_status, text_char_count (omits `decision_text`, which needs an Enterprise plan) |
| `BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts`, `get_budget_account` | id, federal_account_symbol, fiscal_year, agency_code/name, bureau_name, account_title, bea_category, on_off_budget, subfunction_code, lifecycle (requested/enacted/apportioned/obligated/outlayed/unobligated), contract & assistance rollups, key ratios, next-year growth |
| `VEHICLE_ORDERS_MINIMAL` | `list_vehicle_orders` | key, piid, award_date, recipient(display_name,uei), total_contract_value, obligated |
| `ITDASHBOARD_INVESTMENTS_MINIMAL` | `list_itdashboard_investments` | Minimal IT Dashboard investment fields |
Expand Down
1 change: 1 addition & 0 deletions docs/WEBHOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ tango webhooks list-event-types
# alerts.dibbs_rfq.match DLA DIBBS RFQ matched a saved alert
# alerts.dibbs_rfp.match DLA DIBBS RFP matched a saved alert
# alerts.sled_opportunity.match State/local/education solicitation matched a saved alert
# alerts.contract_appeal.match CBCA/ASBCA appeal decision matched a saved alert
```

This list is served by the API, so it is always current — the SDK does not hardcode
Expand Down
1 change: 1 addition & 0 deletions scripts/check_filter_shape_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"sled/opportunities": "list_sled_opportunities",
"sled/forecasts": "list_sled_forecasts",
"protests": "list_protests",
"contract_appeals": "list_contract_appeals",
"offices": "list_offices",
"psc": "list_psc",
"mas_sins": "list_mas_sins",
Expand Down
1 change: 1 addition & 0 deletions scripts/check_shape_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"budget/accounts": "BudgetAccount",
"budget_accounts": "BudgetAccount",
"protests": "Protest",
"contract_appeals": "ContractAppeal",
"offices": "Office",
"assistance_listings": "AssistanceListing",
"business_types": "BusinessType",
Expand Down
2 changes: 2 additions & 0 deletions tango/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from .models import (
BudgetAccount,
ContractAppeal,
DibbsAward,
DibbsRfp,
DibbsRfq,
Expand Down Expand Up @@ -65,6 +66,7 @@
"ResolveCandidate",
"ResolveResult",
"BudgetAccount",
"ContractAppeal",
"DibbsAward",
"DibbsRfp",
"DibbsRfq",
Expand Down
Loading
Loading