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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ This project follows [Semantic Versioning](https://semver.org/).

### Added

- **Boards-of-contract-appeals decisions** (Tango API 4.26.0). `listContractAppeals(options)` and `getContractAppeal(uuid, options)` over `/api/contract_appeals/`, with every filter the API accepts declared as a typed option on `ListContractAppealsOptions` (`search`, `board`, `docket`, `appellant`, `judge`, `decision_type`, the `decision_date_after` / `_before` pair, `listed`, `document_id`, `ordering`), the new `ContractAppealRecord` return type, and a registered `ContractAppeal` shape schema so the typed shape API resolves the resource's fields.

These are Contract Disputes Act decisions from the CBCA (civilian) and the ASBCA (defense) — a dispute under an existing contract, not a challenge to an award. Bid protests remain the separate `listProtests()` resource, and the two do not overlap.

Two properties are documented on the record type and in `docs/API_REFERENCE.md`. An unshaped list returns only a **core subset** of the decision, so every property on `ContractAppealRecord` is optional and anything past the subset has to be named in a `shape`. And `decision_text` is **Enterprise-only, with the key absent rather than null** below that tier — a caller checks for presence, not for a nullish value.

- **`attachments(extracted_text)` — SLED document bodies on the Small plan and above** (Tango API 4.25.1; parity with tango-python). The leaf joins `SledAttachmentPayload` and the explicit shape schema, and the contract was re-vendored so it validates instead of being rejected client-side. Three properties are documented on the interface and in `docs/API_REFERENCE.md`: the leaf must be **named** (neither `ShapeConfig` default includes it, and `attachments(*)` does not carry it, because the API only resolves the body 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**. Searching document text stays ungated on every plan and returns no fragment of it.

- **State, local and education (SLED) procurement support** (Tango API 4.25.0; parity with tango-python). Six methods over the new `/api/sled/` namespace: `listSledOpportunities`/`getSledOpportunity`, `listSledOpportunityRevisions`, `getSledCoverage`, `listSledForecasts`/`getSledForecast`, plus `iterateSledOpportunities` / `iterateSledForecasts` and their `IterableListMethod` entries. New model interfaces `SledOpportunity`, `SledOpportunityRevision`, `SledForecast` and the six nested payload types; explicit shape schemas for all of them; five `ShapeConfig` defaults. Every one of the API's 27 solicitation filters and 13 forecast filters is a typed option.
Expand All @@ -23,7 +29,7 @@ This project follows [Semantic Versioning](https://semver.org/).
### Changed

- Re-vendored `contracts/filter_shape_contract.json` (schema_version 2, 48 resources) and regenerated `src/shapes/generatedOverlay.ts` from it — 359 fields across 25 containers, 73 nested schemas.
- Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win. Contract appeals and eBuy requests are in the contract without SDK methods yet, so both are baselined as tracked gaps.
- Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win. eBuy requests are in the contract without an SDK method yet, so it is baselined as a tracked gap.
- Baselined 14 reverse-shape-coverage gaps in `contracts/shape_coverage_baseline.json`, matching tango-python. All 14 are the nested sub-resource routes above, which reuse the parent resource's model rather than carrying one of their own; none is SLED, and none is a regression — they became visible only with the re-vendored contract.

### Fixed
Expand All @@ -35,6 +41,7 @@ This project follows [Semantic Versioning](https://semver.org/).

### Documentation

- New **Contract Appeals** section in `docs/API_REFERENCE.md` covering both methods, the full filter table, and the two properties that catch people out (the core-subset default and the tier-gated, absent-rather-than-null `decision_text`). `README.md`'s method list gained both methods.
- New **State & Local (SLED) — Beta** section in `docs/API_REFERENCE.md` covering all six methods, both defaults that surprise people, and the new `ShapeConfig` constants.
- `docs/WEBHOOKS.md` troubleshooting gained the date-lapse rule and its one exception. An exclusion or a 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.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,11 @@ The Node.js client mirrors the Python SDK's high-level API. Selected highlights:
- `getForecast(id, options)` / `getOpportunity(opportunityId, options)` / `getNotice(noticeId, options)` / `getGrant(grantId, options)`
- `searchOpportunityAttachments(options)`

**GSA eLibrary / Protests / IT Dashboard / LCATs**
**GSA eLibrary / Protests / Contract Appeals / IT Dashboard / LCATs**

- `listGsaElibraryContracts(options)` / `getGsaElibraryContract(uuid, options)`
- `listProtests(options)` / `getProtest(caseId)`
- `listContractAppeals(options)` / `getContractAppeal(uuid, options)`
- `listItDashboard(options)` / `getItDashboard(uii)`
- `listLcats(options)` / `listIdvLcats(key, options)`

Expand Down
1 change: 0 additions & 1 deletion contracts/conformance_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"_comment": "Accepted SDK coverage gaps vs the API contract. Gaps listed here downgrade from error to warning in scripts/check-filter-shape-conformance.ts. Each entry is tracked backlog: remove it in the same PR that closes the gap in the SDK. `missing_filters` maps a resource to filter params the mapped method does not expose; `unimplemented_resources` lists contract resources with no SDK method at all. events and news are content endpoints with no list method and stay baselined permanently (tango-python does the same).",
"missing_filters": {},
"unimplemented_resources": [
"contract_appeals",
"ebuy/requests",
"events",
"news"
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.ts 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)|(no model mapped)",
"unmapped_resource|agencies_contracts_funding|(root)|(no model mapped)",
"unmapped_resource|contract_appeals|(root)|(no model mapped)",
"unmapped_resource|contracts_subawards|(root)|(no model mapped)",
"unmapped_resource|ebuy/requests|(root)|(no model mapped)",
"unmapped_resource|entities_contracts|(root)|(no model mapped)",
Expand Down
45 changes: 45 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,51 @@ Takes the case's `case_id` UUID, not a case number. To look a case up by number,

---

## Contract Appeals

Contract Disputes Act decisions from the two boards of contract appeals — the CBCA (civilian) and the ASBCA (defense). These are disputes under an existing contract; a challenge to an award is a bid protest, which is the separate [Protests](#protests) resource.

### `listContractAppeals(options?)`

```ts
const appeals = await client.listContractAppeals({
board: "asbca",
decision_date_after: "2026-01-01",
limit: 25,
});
```

#### Parameters (Contract Appeals)

| Name | Type | Description |
| --------------------------------- | --------- | -------------------------------------------------------------------------------------- |
| `search` | `string` | Ranked full-text search across the decision. |
| `board` | `string` | `cbca` or `asbca`. |
| `docket` | `string` | Docket number, as the board publishes it. |
| `appellant` | `string` | Appellant name. |
| `judge` | `string` | Deciding judge. |
| `decision_type` | `string` | Normalized decision type. |
| `decision_date_after` / `_before` | `string` | ISO date bounds on the decision date. |
| `listed` | `boolean` | Whether the decision is currently on the board's published listing. |
| `document_id` | `string` | The board's own document identifier. |
| `ordering` | `string` | `decision_date` (default `-decision_date`), `appellant`, `first_listed_at`, or `rank`. |

`rank` ordering is only meaningful with a non-empty `search`. The standard `page` / `limit` / `shape` / `flat` / `flatLists` / `joiner` options apply.

Without a `shape` the API returns a core subset of the decision, so name the rest explicitly when you need it. `decision_text` is served on the Enterprise tier only, and below it the key is **absent rather than null** — test for presence, not for a nullish value.

### `getContractAppeal(uuid, options?)`

```ts
const appeal = await client.getContractAppeal("00000000-0000-0000-0000-000000000001", {
shape: "uuid,board,docket_numbers,decision_date,appellant,judge,decision_text",
});
```

Returns a `ContractAppealRecord`. Every property on it is optional, for the same two reasons: an unshaped list carries only the core subset, and `decision_text` is tier-gated.

---

## IT Dashboard

### `listItDashboard(options?)`
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-filter-shape-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export const RESOURCE_TO_METHOD: Record<string, string | null> = {
budget_accounts: "listBudgetAccounts",
offices: "listOffices",
protests: "listProtests",
// Contract appeals and eBuy requests are published by the API but not yet ported — baselined as tracked gaps.
contract_appeals: null,
contract_appeals: "listContractAppeals",
// eBuy requests are published by the API but not yet ported — baselined as a tracked gap.
"ebuy/requests": null,
psc: "listPsc",
mas_sins: "listMasSins",
Expand Down
1 change: 1 addition & 0 deletions scripts/check-shape-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const RESOURCE_TO_MODEL: Record<string, string> = {
"budget/accounts": "BudgetAccount",
budget_accounts: "BudgetAccount",
protests: "Protest",
contract_appeals: "ContractAppeal",
offices: "Office",
assistance_listings: "AssistanceListing",
business_types: "BusinessType",
Expand Down
67 changes: 66 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import type { ShapeSpec } from "./shapes/types.js";
import { isRecord } from "./utils/guards.js";
import { HttpClient } from "./utils/http.js";
import { unflattenResponse } from "./utils/unflatten.js";
import { AgencyRecord, PaginatedResponse, ProtestRecord, RateLimitInfo, ResolveResult, TangoClientOptions, ValidateResult } from "./types.js";
import {
AgencyRecord,
ContractAppealRecord,
PaginatedResponse,
ProtestRecord,
RateLimitInfo,
ResolveResult,
TangoClientOptions,
ValidateResult,
} from "./types.js";
import type {
WebhookAlert,
WebhookAlertCreateInput,
Expand Down Expand Up @@ -951,6 +960,34 @@ export interface ListProtestsOptions {
[key: string]: unknown;
}

/**
* Contract-appeal list options.
*
* Contract appeals are Contract Disputes Act decisions from the civilian (CBCA) and defense (ASBCA) boards of contract appeals — disputes under an existing contract. A challenge to an award is a bid protest, which is the separate `listProtests()` resource.
*/
export interface ListContractAppealsOptions extends ListOptionsBase {
/** Separator for flattened keys. Only meaningful alongside `flat`. */
joiner?: string;
/** Ranked full-text search across the decision. */
search?: string;
/** Deciding board — `cbca` (civilian) or `asbca` (defense). */
board?: string;
/** Docket number, as the board publishes it. */
docket?: string;
appellant?: string;
judge?: string;
decision_type?: string;
decision_date_after?: string;
decision_date_before?: string;
/** Whether the decision is currently on the board's published listing. */
listed?: boolean;
/** The board's own document identifier. */
document_id?: string;
/** Sort field (decision_date, appellant, first_listed_at, rank). Defaults to `-decision_date`; `rank` is only meaningful with a non-empty `search`. */
ordering?: string;
[key: string]: unknown;
}

export interface ListItDashboardOptions {
page?: number;
limit?: number;
Expand Down Expand Up @@ -2646,6 +2683,34 @@ export class TangoClient {
return await this.http.get<AnyRecord>(`/api/protests/${encodeURIComponent(caseId)}/`);
}

/**
* List boards-of-contract-appeals decisions (`/api/contract_appeals/`).
*
* These are Contract Disputes Act appeals decided by the CBCA (civilian) and the ASBCA (defense) — a dispute under an existing contract, not a challenge to an award. Bid protests are `listProtests()`.
*
* Without a `shape` the API returns a core subset of the decision, so shape explicitly for the rest. `decision_text` is Enterprise-only and its key is absent, not null, below that tier.
*/
async listContractAppeals(options: ListContractAppealsOptions = {}): Promise<PaginatedResponse<AnyRecord>> {
return this._genericPaginatedList("/api/contract_appeals/", options);
}

/** Get a single contract-appeal decision by uuid (`/api/contract_appeals/{uuid}/`). */
async getContractAppeal(
uuid: string,
options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {},
): Promise<ContractAppealRecord> {
if (!uuid) throw new TangoValidationError("Contract appeal uuid is required");
const { shape, flat, flatLists, joiner } = options;
const params: AnyRecord = {};
if (shape) params.shape = shape;
if (flat) {
params.flat = "true";
if (joiner) params.joiner = joiner;
}
if (flatLists) params.flat_lists = "true";
return await this.http.get<AnyRecord>(`/api/contract_appeals/${encodeURIComponent(uuid)}/`, params);
}

/** List IT Dashboard investments. */
async listItDashboard(options: ListItDashboardOptions = {}): Promise<PaginatedResponse<AnyRecord>> {
return this._genericPaginatedList("/api/itdashboard/", options);
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {
ListSledOpportunityRevisionsOptions,
ListSledForecastsOptions,
ListProtestsOptions,
ListContractAppealsOptions,
ListItDashboardOptions,
ListMetricsOptions,
ResolveInput,
Expand Down
152 changes: 152 additions & 0 deletions src/shapes/explicitSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3467,6 +3467,157 @@ export const PROTEST_SCHEMA: FieldSchemaMap = {
},
};

// Boards-of-contract-appeals decision (CBCA + ASBCA). `decision_text` is Enterprise-only, so below that tier the key is absent rather than null.
export const CONTRACT_APPEAL_SCHEMA: FieldSchemaMap = {
uuid: {
name: "uuid",
type: "str",
isOptional: false,
isList: false,
nestedModel: null,
},
board: {
name: "board",
type: "str",
isOptional: false,
isList: false,
nestedModel: null,
},
docket_numbers: {
name: "docket_numbers",
type: "str",
isOptional: false,
isList: true,
nestedModel: null,
},
docket_source: {
name: "docket_source",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
docket_raw: {
name: "docket_raw",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
decision_date: {
name: "decision_date",
type: "date",
isOptional: true,
isList: false,
nestedModel: null,
},
decision_date_raw: {
name: "decision_date_raw",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
decision_date_repaired: {
name: "decision_date_repaired",
type: "bool",
isOptional: false,
isList: false,
nestedModel: null,
},
appellant: {
name: "appellant",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
judge: {
name: "judge",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
decision_type: {
name: "decision_type",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
decision_type_raw: {
name: "decision_type_raw",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
url: {
name: "url",
type: "str",
isOptional: false,
isList: false,
nestedModel: null,
},
document_id: {
name: "document_id",
type: "str",
isOptional: false,
isList: false,
nestedModel: null,
},
listing_url: {
name: "listing_url",
type: "str",
isOptional: false,
isList: false,
nestedModel: null,
},
listing_year: {
name: "listing_year",
type: "int",
isOptional: true,
isList: false,
nestedModel: null,
},
first_listed_at: {
name: "first_listed_at",
type: "datetime",
isOptional: false,
isList: false,
nestedModel: null,
},
listed: {
name: "listed",
type: "bool",
isOptional: false,
isList: false,
nestedModel: null,
},
text_status: {
name: "text_status",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
text_char_count: {
name: "text_char_count",
type: "int",
isOptional: true,
isList: false,
nestedModel: null,
},
decision_text: {
name: "decision_text",
type: "str",
isOptional: true,
isList: false,
nestedModel: null,
},
};

// GSA eLibrary IDV reference (the linked IDV summary on a GSA eLibrary contract)
export const GSA_ELIBRARY_IDV_REF_SCHEMA: FieldSchemaMap = {
key: {
Expand Down Expand Up @@ -5613,6 +5764,7 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = {
Notice: NOTICE_SCHEMA,
Protest: PROTEST_SCHEMA,
ProtestDocket: PROTEST_DOCKET_SCHEMA,
ContractAppeal: CONTRACT_APPEAL_SCHEMA,
Agency: AGENCY_SCHEMA,
Grant: GRANT_SCHEMA,
Vehicle: VEHICLE_SCHEMA,
Expand Down
Loading
Loading