node-autotask 0.33.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/MCP_TOOL_CATALOG.json +26 -9
  3. package/MCP_TOOL_MANIFEST.md +14 -13
  4. package/N8N_MCP_MANIFEST.json +5 -5
  5. package/N8N_MCP_MANIFEST.md +4 -4
  6. package/README.md +12 -5
  7. package/capabilities.json +5850 -2557
  8. package/capabilities.schema.json +36 -0
  9. package/dist/capabilities.cjs +2 -2
  10. package/dist/capabilities.d.cts +15 -1
  11. package/dist/capabilities.d.ts +15 -1
  12. package/dist/capabilities.js +1 -1
  13. package/dist/{chunk-7WNE5MPG.js → chunk-2PJCQGXZ.js} +12 -12
  14. package/dist/{chunk-OO67MRHN.cjs → chunk-4GHBN2AW.cjs} +1737 -6620
  15. package/dist/{chunk-5DS6F6GF.js → chunk-75WJS26D.js} +3594 -8477
  16. package/dist/{chunk-3LAZZT7V.cjs → chunk-7WR5WUQT.cjs} +143 -143
  17. package/dist/{chunk-MFDQHFGJ.js → chunk-CH2WXTYD.js} +9445 -3678
  18. package/dist/{chunk-4QN43TRP.cjs → chunk-FYF46OT3.cjs} +9470 -3703
  19. package/dist/{chunk-SP2SJJZX.cjs → chunk-HBYKTXN3.cjs} +5849 -2556
  20. package/dist/{chunk-VKE56TTN.js → chunk-KQ6HMBB4.js} +5849 -2556
  21. package/dist/{index-BaeMHZPk.d.ts → index-DAFg_68I.d.ts} +584 -207
  22. package/dist/{index-Cq0VsfSz.d.cts → index-g8J6Qiu1.d.cts} +584 -207
  23. package/dist/index.cjs +22 -22
  24. package/dist/index.d.cts +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +14 -14
  27. package/dist/operations/index.cjs +867 -135
  28. package/dist/operations/index.d.cts +182 -28
  29. package/dist/operations/index.d.ts +182 -28
  30. package/dist/operations/index.js +742 -10
  31. package/dist/pool.cjs +9 -9
  32. package/dist/pool.d.cts +1 -1
  33. package/dist/pool.d.ts +1 -1
  34. package/dist/pool.js +4 -4
  35. package/dist/resources/index.cjs +5 -4
  36. package/dist/resources/index.d.cts +1 -1
  37. package/dist/resources/index.d.ts +1 -1
  38. package/dist/resources/index.js +14 -13
  39. package/docs/API.md +481 -46
  40. package/docs/LIVE-TESTING.md +11 -11
  41. package/package.json +1 -1
package/docs/API.md CHANGED
@@ -270,6 +270,7 @@ status → what the vendor does that triggers it.
270
270
  | `DeleteBoundaryError` | `DELETE_BOUNDARY` | policy | — | `operations.recovery` `action: 'delete'` on a resource the vendored spec defines no DELETE for — refused PRE-SEARCH (zero wire; the boundary is the spec-derived plan audit, never a raw vendor 405; issue #9) |
271
271
  | `MarkerUnsafeIdError` | `MARKER_UNSAFE_ID` | policy | — | a `operations.recovery` match whose id is beyond `Number.MAX_SAFE_INTEGER` (unaddressable through the typed delete surface) — refused with the typed code, never a silent skip (issue #9, P1) |
272
272
  | `RecoveryTruncatedError` | `RECOVERY_TRUNCATED` | policy | — | the `operations.recovery` marker search tripped its match cap (more matches than `limit`) — refused instead of a silent truncation / silent empty (issue #9) |
273
+ | `AutotaskError` | `BUDGET_EXCEEDED` | policy | — (the call that would exceed it is not sent) | `operations.revenueRollup`: the pre-walk estimate exceeds `maxVendorCalls` (refused before any stream; `details: { phase: 'estimate', estimate, budget, suggestions }`) or the counting wrapper reached the budget mid-walk (`phase: 'walk'`; the partial fold is discarded) — `suggestedAction` names the narrowing (issue #234) |
273
274
 
274
275
  Retryable by contract: `NETWORK_ERROR`, `TIMEOUT`, `RATE_LIMITED`,
275
276
  `SERVER_ERROR` (429/5xx) — with ONE exception: a deterministic vendor
@@ -953,6 +954,31 @@ out entirely. `cache.store` is otherwise a plain advanced seam: a supplied
953
954
  `ReadCache` instance is used as-is, and `ttlMs` / `perResourceTtlMs` beside it
954
955
  refuse (the instance owns its TTLs).
955
956
 
957
+ ### Fresh single-record reads (`fresh: true`, issue #250)
958
+
959
+ A write made ELSEWHERE (another process, the Autotask UI, a client with a
960
+ private cache) does not void this client's read cache, so a read-back can be
961
+ served a snapshot. Every single-record `get` takes a trailing per-call
962
+ `{ fresh: true }` — `client.companies.get(id, { fresh: true })`, parent-scoped
963
+ `client.companyAlerts.get(parentId, id, { fresh: true })` — which skips the
964
+ cache LOOKUP, always issues the wire read and refreshes the entry with the
965
+ response (the default, no option or `fresh: false`, is the unchanged cached
966
+ read). Through the governed path pass `invokeRead(client, 'companies.get',
967
+ [id], { fresh: true })`: `InvokeOptions.fresh` is honoured ONLY on a
968
+ `<resource>.get` read; any other operation, a write, or a non-boolean value
969
+ refuses pre-request (zero wire). It affects the one read only: it never clears
970
+ other entries (unlike `http.readCache.clear()`).
971
+
972
+ Stale-response safety: the read cache does not coalesce concurrent reads (every
973
+ miss and every `fresh` call issues its own request), so a `fresh` read never
974
+ joins a read that started before a write. A read in flight across a write
975
+ through the same cache (including the pool's shared per-tenant cache, whose
976
+ epoch the write bumps) has its late store discarded, so a pre-write response is
977
+ never re-cached; the in-flight caller itself still receives the response it
978
+ asked for. Not covered: a write on a client whose cache is private (pool
979
+ `sharedReadCache: false`) is invisible to
980
+ the other client's cache — use `fresh` there.
981
+
956
982
  ## 7. Operations surface (`./operations` subpath)
957
983
 
958
984
  Cross-resource helpers live in `src/operations/` (placement locked —
@@ -1081,13 +1107,13 @@ be cross-mode-refused.
1081
1107
  and the `confirm === <op key>` gate (all 54 deletes; the 3 approval-gated
1082
1108
  writes — inside `autotask_write` too) apply exactly as in `invoke`.
1083
1109
  Pinned by the full-surface suite (`test/operations/invoke/modes.test.ts`:
1084
- all 1275 ops × 3 modes, zero wire).
1110
+ all 1281 ops × 3 modes, zero wire).
1085
1111
 
1086
1112
  **Harness approval policy (recommended).** Require approval on every tool
1087
1113
  whose MCP annotation is `readOnlyHint !== true` — today exactly **267
1088
1114
  tools**: the 265 dedicated write/delete tools (209 `effect=write` + 56
1089
1115
  `effect=destructive`, every one of them has a dedicated tool) +
1090
- `autotask_write` + `autotask_delete`. The remaining **634 tools** (the 621 dedicated read tools + the 10 core read tools + `autotask_read` +
1116
+ `autotask_write` + `autotask_delete`. The remaining **635 tools** (the 622 dedicated read tools + the 10 core read tools + `autotask_read` +
1091
1117
  `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
1092
1118
  and are safe ungated: every op they can route is `effect=read` (zero
1093
1119
  mutating requests — pinned by the test inventory, §6 of the split design).
@@ -1484,7 +1510,14 @@ is closed when its label is complete, completed, closed, cancelled, canceled or
1484
1510
  resolved. When the catalog cannot be read, or carries no recognisable closed
1485
1511
  label, the historical id `5` stands in with `source: 'assumed'` and a `note`
1486
1512
  that says so. Passing `excludeStatusIds` reports those ids instead
1487
- (`source: 'picklist'` — an explicit caller choice). No module outside this
1513
+ (`source: 'picklist'` — an explicit caller choice). `allowAssumed: false`
1514
+ (default `true`, behaviour unchanged) refuses the fallback: an unreadable
1515
+ catalog or one with no closed label throws an `AutotaskError` with code
1516
+ `STATUS_SEMANTICS_UNRESOLVED` instead of returning `[5]`; the same option is
1517
+ accepted by `tickets.forResource` and `companies.fullProfile`, and
1518
+ `companies.fullProfile` also takes `excludeStatusIds` (an operator pin reaches
1519
+ its `openTickets` part, with no catalog read). In `fullProfile` the throw comes
1520
+ at the `openTickets` read, after the company and the four other part reads. No module outside this
1488
1521
  resolver holds a status literal: the move engine's closed-target refusal,
1489
1522
  `companies.fullProfile`'s `openTickets` part and `tickets.forResource`'s
1490
1523
  `openOnly` all call it and carry the disclosure (`closedStatusIds` /
@@ -1499,21 +1532,233 @@ default `both`; the seam is `Tickets.assignedResourceID` for primary and the
1499
1532
  both `id in [...]`-chunked when the link set is large) unioned client-side —
1500
1533
  deliberately not the cross-field grouped `or`, whose live semantics are
1501
1534
  unverified. Result: `{ resourceID, scope, openOnly, tickets, primaryIds,
1502
- secondaryIds, bothIds, counts, capped, unresolvedIds, closedStatus }`, newest
1503
- first by `id` desc, each ticket projected to `id`, `ticketNumber`, `title`,
1535
+ secondaryIds, bothIds, counts, capped, unresolvedIds, closedStatus }`, ascending
1536
+ by `id`, each ticket projected to `id`, `ticketNumber`, `title`,
1504
1537
  `status`, `assignedResourceID`, `companyID`. `openOnly: true` applies the
1505
1538
  resolved open filter server-side (`status notIn` the closed ids, or a single
1506
1539
  `not-eq` leaf) and re-applies it client-side so a server that ignores the leaf
1507
1540
  cannot widen the answer; `closedStatus` carries the disclosure above.
1508
1541
  `limit` (1-500, default 100) caps the returned tickets — `capped: true` means
1509
- the cap bit, and the link read is capped at 500 rows (`capped` also reflects
1510
- that). `unresolvedIds` lists ids the ticket read did not return (deleted or
1511
- invisible). `excludeStatusIds` overrides the closed set for this call.
1542
+ rows were actually left out (the primary read asks for `limit + 1` rows; exactly
1543
+ `limit` rows is not capped), and the link read is one 500-row page (`capped` is
1544
+ true when the vendor reports a further page; `countOnly` below pages further). `unresolvedIds` lists ids the
1545
+ ticket read did not return (deleted or invisible); under `openOnly`, tickets the
1546
+ open filter dropped because they are closed are NOT listed (one extra unfiltered
1547
+ id read per chunk tells them apart). `excludeStatusIds` overrides the closed set for this
1548
+ call.
1549
+
1550
+ `countOnly: true` (issue #248) returns counts only, no ticket rows:
1551
+ `{ resourceID, scope, primary, secondary, both, open, capped, closedStatus }`
1552
+ (typed overloads: `countOnly: true` narrows the return type to
1553
+ `TicketsForResourceCountResult`). Every figure counts OPEN tickets (`openOnly`
1554
+ and `limit` do not apply). `primary` is the `tickets.count` path with the filter
1555
+ `assignedResourceID eq R` and the open leaf, so it equals that call;
1556
+ `secondary` is the de-duplicated count of open secondary ticket ids (the link
1557
+ table has no status, so the open filter is an ids-only `id in` read per 100
1558
+ link ids); `both` is the overlap and `open` the union
1559
+ (`primary + secondary - both`). The link read follows the vendor's next-page
1560
+ links (ids only, 500 per page) up to 20 pages, so the count is exact below
1561
+ 10,000 links; `capped: true` means the cap was hit with pages remaining, so
1562
+ `secondary`, `both` and `open` may undercount; it is never a silent undercount.
1563
+ The secondary read re-applies the open filter client-side, and `limit` is not
1564
+ validated (it does not apply). With `scope` `primary`/`secondary` only that side is
1565
+ read and `both` is 0.
1566
+
1567
+ ### 7.14 `revenueRollup(client, opts)` — revenue by dimension, budgeted (issues #234, #235, #236)
1568
+
1569
+ One read call answers "revenue for Recurring Service + Fixed Price contracts
1570
+ over a window, by account vertical": the registry op `operations.revenueRollup`
1571
+ (MCP tool `autotask_revenue_rollup`, tier extended).
1572
+
1573
+ - **Scope** (exactly one, REQUIRED — no scope is refused with zero wire):
1574
+ `companyIDs` | `ownerResourceID` (resolved to company ids by one projected
1575
+ `companies` query) | `contractIDs` | `tenantWide: true`. A company scope
1576
+ filters on the LINE's `companyID` (attribution is by the line's company).
1577
+ - **Window**: `dateFrom` / `dateTo` (`YYYY-MM-DD`, both REQUIRED, inclusive,
1578
+ whole UTC days, at most 36 months), invoice-date basis.
1579
+ The window is whole calendar days sent to the vendor as bare `YYYY-MM-DD`
1580
+ bounds; that was verified on a tenant ahead of UTC only, and tenants behind
1581
+ UTC are unverified.
1582
+ - **Revenue** = Σ `billing_items.internalCurrencyTotalAmount` (ex-tax, internal
1583
+ currency) over the lines of NON-VOIDED invoices (`isVoided eq false`
1584
+ server-side) in the window, folded in integer cents.
1585
+ `includedValue` = Σ(`internalCurrencyExtendedPrice` − total) is
1586
+ informational and never added to revenue. Credits (negative lines) are
1587
+ netted and also reported per row. A line whose `internalCurrencyTotalAmount`
1588
+ / `internalCurrencyExtendedPrice` is missing or null falls back to the
1589
+ local-currency `totalAmount` / `extendedPrice` (as contract profitability
1590
+ does), everywhere those fields are read (revenue, `includedValue`, the
1591
+ non-chargeable class and value); never silently: one
1592
+ `coverage.blindSurfaces` entry gives the count of such lines.
1593
+ - **`contractTypes`** (ids or exact labels) default to the live picklist
1594
+ labels that START with `Recurring` or `Fixed` (case-insensitive,
1595
+ `/^\s*(recurring|fixed)\b/i`: `Non-Recurring` does not match); the resolved map is echoed in
1596
+ `coverage.contractTypeMap`. No match is a `CONFIG_ERROR` listing the live
1597
+ labels and ids. `contractCategories` filters the same way, and when given
1598
+ its resolved map is echoed in `coverage.contractCategoryMap` (`{ "<id>":
1599
+ label }`, absent otherwise). A label shared by several live picklist ids
1600
+ selects every one of them.
1601
+ - **`groupBy`** (default `['vertical', 'contractType']`): `vertical`
1602
+ (`companies.marketSegmentID`), `contractType`, `contractCategory`,
1603
+ `company`, `month` (the invoice month), `billingItemKind`, and the join
1604
+ dimensions (#235):
1605
+ - `contract` — `{ id, name }` (`contractName` joins the contract fetch).
1606
+ - `lineOfBusiness` — per billing line (`organizationalLevelAssociationID`;
1607
+ no contract-pointer fallback): three batch `id in` fetches (associations,
1608
+ level1s, level2s) give `{ id: associationId, name: level2Name (the Line of
1609
+ Business), division: level1Name }`. Lines without a pointer form their own
1610
+ row `{ id: null, label: 'no LOB assigned' }`, and
1611
+ `coverage.lineOfBusiness = { noPointer: { amount, lines }, note }` carries
1612
+ their amount (LOB figures are floors). A row's `contracts` is "contracts
1613
+ with at least one line in this LOB", so per-LOB counts can sum to more than
1614
+ `totals.contracts`.
1615
+ - `resource` — labour lines only (`billingItemType` Labor / Labor
1616
+ Adjustment, or the FK fallback): `timeEntryID` → time entry → resource,
1617
+ `{ id: resourceID, name: 'First Last' }`. Non-labour lines, and labour
1618
+ lines with no time entry (e.g. an adjustment), are `{ id: null, label: 'n/a' }`.
1619
+ - `billingCode` — `billingCodeID` → billing code `{ id, name }`; a line
1620
+ without one is `{ id: null, label: 'none' }`.
1621
+
1622
+ Each row key is `{ id, label?, name?, division? }`: picklist text in
1623
+ `label`; vendor entity text (untrusted — company, contract, LOB level2,
1624
+ resource and billing-code names) in `name`, and the LOB division (level1
1625
+ name, also untrusted) in `division`. Missing values get their own row
1626
+ (`unassigned`, `uncategorised`, `no LOB assigned`, `n/a`, `none`); a value
1627
+ the picklist does not serve stays id-only and is listed in
1628
+ `coverage.blindSurfaces`. A join id the `id in` fetch does not return is
1629
+ never guessed: the line goes to `coverage.excluded.unresolvedAssociations`
1630
+ (association or its level1/level2), `unresolvedTimeEntries` (time entry or
1631
+ its resource) or `unresolvedBillingCodes`, with its amount. These buckets
1632
+ (and `coverage.lineOfBusiness`) appear only when their dimension is grouped;
1633
+ the line projection grows by `organizationalLevelAssociationID`,
1634
+ `timeEntryID` / the type fields and `billingCodeID` the same way.
1635
+ - **Rows** are sorted by revenue descending, then ids; `top` (default 100,
1636
+ max 100) keeps the head and folds the tail into `rowsTruncated`. `totals`
1637
+ always cover every row.
1638
+ - **`coverage.excluded`**: voided invoices (count + header amount; under a
1639
+ company / owner scope only the voided invoices whose HEADER `companyID` is
1640
+ in scope; omitted for a `contractIDs` scope, since an invoice header cannot
1641
+ be attributed to a contract, and noted in `coverage.notes`), uncontracted
1642
+ lines, other contract types (per label), other contract categories (per
1643
+ label; a label shared by several live ids is keyed `label (id)`, so ids
1644
+ are never merged) and
1645
+ unresolved contracts / companies (ids the `id in` fetch did not return; an
1646
+ unresolved contract is never counted as uncontracted), each with an amount
1647
+ and a line count. Every line is in exactly one place, so `totals.revenue` +
1648
+ Σ the excluded line buckets = Σ the lines fetched (lines are de-duplicated
1649
+ by id before the fold).
1650
+ - **`coverage.zeroTotalInvoices`** `{ count, amount, lines }` is an
1651
+ informational overlay, NOT an excluded bucket: the zero-total invoices in
1652
+ scope (tenant-wide: all; company / owner scope: header company in scope or
1653
+ carrying an in-scope line; `contractIDs`: carrying a scoped line) and the
1654
+ amount / count of their lines. Those lines are still folded into rows or the
1655
+ excluded buckets.
1656
+ - **`includeNonChargeable`** (#236, default `false`; when `false` the call,
1657
+ its wire and its output are byte-identical to leaving it out): adds
1658
+ `nonChargeable: { contractCovered: { value, hours, lines }, nonBillable:
1659
+ { value, hours, lines } }` to every row and to `totals`. It is NEVER added to
1660
+ `revenue` / `totals.revenue`. Classes per line: non-billable =
1661
+ `nonBillable` true; contract-covered = billable with
1662
+ `internalCurrencyExtendedPrice` > 0 and `internalCurrencyTotalAmount` = 0.
1663
+ `value` = Σ `internalCurrencyExtendedPrice` (integer cents); `hours` = Σ
1664
+ `quantity` on labour lines, Labor Adjustment (type 2) included (it adjusts
1665
+ labour hours, and `billingLineType` already classes it as labour). The
1666
+ invoiced part comes from the revenue line stream (+ `nonBillable`,
1667
+ `quantity` and the type fields); the uninvoiced part is a second unsliced
1668
+ stream `invoiceID notExist` with `itemDate` in the window (+ the same
1669
+ scope). Uninvoiced lines have no invoice date, so they are windowed and
1670
+ month-keyed on `itemDate` (stated in `coverage.notes`). They are keyed
1671
+ through the same joins (one fetch over both streams, so only ids the
1672
+ invoiced lines did not already need are added); uninvoiced lines that are
1673
+ uncontracted or of another contract type / category are outside the
1674
+ request. A non-chargeable line, invoiced or uninvoiced, whose row cannot be
1675
+ resolved (an unresolved contract / company or a join miss) goes to
1676
+ `coverage.excluded.unresolvedNonChargeable` (amount = Σ value); an invoiced
1677
+ one is also in its revenue excluded bucket. The default `maxVendorCalls` becomes 150 (an explicit
1678
+ value wins; cap 200).
1679
+ - **`coverage.reconciliation`**: header `invoiceTotal` vs Σ line `totalAmount`
1680
+ per invoice (tolerance 0 cents; `totalAmount`, so a multi-currency tenant
1681
+ gives no false delta). Tenant-wide it covers every invoice; a company scope
1682
+ covers the invoices whose header company is in scope; a `contractIDs`
1683
+ scope skips it (noted).
1684
+ - **Budget** (`maxVendorCalls`, default 90, max 200): every vendor call goes
1685
+ through a counting wrapper. Before any stream the op counts the window's
1686
+ non-voided invoices and the invoiced lines (`itemDate` in the window, a
1687
+ proxy for the lines of those invoices) and estimates
1688
+ `calls so far + 1 (voided; not for a contractIDs scope) + invoice pages + line pages (one per 500-invoice
1689
+ chunk + overflow) + one line count per 500-invoice chunk (the per-chunk line gate) + uninvoiced pages (includeNonChargeable: its count is in
1690
+ "calls so far", ≥ 1 page) + each join's id-fetch chunks`. A join's chunks
1691
+ default to `ceil(min(lines + uninvoiced, invoices) / 500)` (contracts,
1692
+ companies, LOB associations, billing codes); time entries use
1693
+ `ceil((lines + uninvoiced) / 500)` (one per labour line, the labour share is
1694
+ unknown pre-walk); LOB level1s / level2s and resources count one chunk each
1695
+ (small org / staff tables). Over budget it REFUSES with `BUDGET_EXCEEDED`
1696
+ (`details: { phase: 'estimate', estimate, budget, suggestions }`, the
1697
+ message names the narrowings that apply: `maxVendorCalls: N` (while N is
1698
+ within the cap), a later `dateFrom`, a scope, and, when raising the budget
1699
+ cannot help (estimate past the cap), `includeNonChargeable: false`, fewer
1700
+ `groupBy` dimensions, `contractIDs`, or a shorter id list (only for a list of
1701
+ more than one id); at least one suggestion is always present. Only a raised
1702
+ `maxVendorCalls` and a later `dateFrom` carry an estimate: the invoice
1703
+ stream is tenant-wide in every scope and the line stream costs at least one
1704
+ page and one count per chunk of 500 of those invoices, so a scope or shape
1705
+ narrowing does not reduce that part, and when the refusal offers one its
1706
+ message says it may still be refused; when nothing narrower exists the
1707
+ only suggestion is the cap, and it says the call will be refused at that
1708
+ estimate even at the cap). A budget
1709
+ below the sizing overhead (the labels, owner query and count calls that run
1710
+ before the estimate exists: at least 4 calls for the default shape, more with
1711
+ `ownerResourceID`, `includeNonChargeable` or more picklist dimensions) is
1712
+ the same refusal (`phase: 'estimate'`), with `estimate` = that minimum and
1713
+ `suggestions: [{ maxVendorCalls }]`; the full estimate is named once the
1714
+ call is sized.
1715
+ Mid-walk the wrapper throws `BUDGET_EXCEEDED` (`phase: 'walk'`) before the
1716
+ call that would exceed the budget. The wrapper counts LOGICAL requests: the
1717
+ transport retries a 429 / 5xx internally (up to `maxRetries` each) below
1718
+ it, and those retries are not counted in the budget, `vendorCalls` or
1719
+ `tenantHourlyShare`. A 429 propagates as `RATE_LIMITED`. These fail as
1720
+ `INVALID_RESPONSE`: a blind gate count (`reason: 'blind_count'`); the invoice
1721
+ stream, each invoice-chunk line stream and the uninvoiced stream
1722
+ (`includeNonChargeable`) whose DISTINCT ids differ from their gate count
1723
+ (`reason: 'count_mismatch'`, `details.stream` names it: a page that repeats
1724
+ one row and drops another is caught; each line chunk is counted on exactly
1725
+ its stream's filter); and a line whose invoice is not among the streamed
1726
+ invoices (`reason: 'orphan_line'`). Retry on a count race (data that changes
1727
+ mid-walk). A repeated voided header is counted once, but a DROPPED voided
1728
+ header is undetectable (there is no voided count call), so
1729
+ `coverage.excluded.voidedInvoices` is a floor under vendor paging drift. Every fault discards the fold: a result is always
1730
+ complete. `coverage.vendorCalls` is the exact logical-request count (joins
1731
+ included; transport retries excluded); ~50
1732
+ calls for a 12-month tenant-wide report on the reference tenant, ~115 with
1733
+ `includeNonChargeable`. `resource` on 12 months tenant-wide adds ~15-25
1734
+ calls and can refuse at the default 90 (the refusal names N).
1735
+ An `ownerResourceID` that owns more than 100 companies is a `CONFIG_ERROR`
1736
+ after at most one page past the 100th id (the resolution stops there).
1737
+ - **Limits**: amounts are rounded to cents per line (`Math.round(x * 100)`)
1738
+ before summing, on the assumption that the vendor sends at most 2 decimals;
1739
+ a 3-decimal amount can drift, and reconciliation (header vs line totals)
1740
+ flags the delta. Non-chargeable `hours` are rounded to hundredths per line
1741
+ before summing. A row that holds only non-chargeable work has revenue 0, so
1742
+ it sorts above rows with negative revenue (net credits). The estimate costs
1743
+ the voided stream as one page and assumes distinct contracts / companies
1744
+ ≤ invoices; a window with more than 500 voided invoices, or (with
1745
+ `includeNonChargeable`) many uninvoiced lines on contracts that never
1746
+ invoiced in the window, can exceed it, and such a call fails mid-walk with
1747
+ `BUDGET_EXCEEDED` (`phase: 'walk'`) rather than returning a partial result.
1748
+
1749
+ ```ts
1750
+ import { revenueRollup } from 'node-autotask/operations';
1751
+ const r = await revenueRollup(client, { tenantWide: true, dateFrom: '2025-07-01', dateTo: '2026-06-30' });
1752
+ // r.rows[0] → { keys: { vertical: { id: 12, label: 'Retail' }, contractType: { id: 7, label: 'Recurring Service' } }, revenue, credits, includedValue, contracts, items }
1753
+ const lob = await revenueRollup(client, { tenantWide: true, dateFrom: '2025-07-01', dateTo: '2026-06-30',
1754
+ contractTypes: ['Recurring Service'], groupBy: ['vertical', 'lineOfBusiness'], includeNonChargeable: true });
1755
+ // lob.rows[i].keys.lineOfBusiness → { id: 7, name: '<level2>', division: '<level1>' } | { id: null, label: 'no LOB assigned' }
1756
+ ```
1512
1757
 
1513
1758
  ## 8. Resource surface (135 resources)
1514
1759
 
1515
1760
  <!-- BEGIN GENERATED:api-per-entity -->
1516
- Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page guarded), `queryBounded` (server-side pagination — one call walks the cursor until `limit` TOTAL records, default 500 / max 2500, with a `hasMore` flag; the MCP `query` surface, routed by `invoke`), `queryAll` (collect → `T[]` — the unbounded raw walk), `count`, `get` (id → record; `item: null` → `NotFoundError`), `create` / `update` / `delete` (where the vendor defines them — mutations take a `MutationOpts` bag: `{ dryRun, lastModifiedDate?, maxRetries? }` (issue #105: `maxRetries` overrides the configured retry budget for every request the mutation issues; `0` = exactly one send per request)), plus the helper floor where the plan declares it: `resolve` (all 121), `search` (40 — bounded contains on the resource text field), `getContext` (5 workflow resources), `findBy<Field>` (3), `createIfNotExists` (14). Entity metadata on every resource: `getEntityInfo`, `getFieldInfo`, `getUdfInfo` (UDF-capable 15). Parent-scoped children take `(parentId, id)` on the CRUD methods and require `parentId` on `createIfNotExists` (the scoped create POSTs the scoped collection; the dedup query stays global); `query`/`count`/`resolve`/`search` stay global (narrow with a `parentField` filter). Scoped `update` PATCHes the collection (the vendor keys the record by the id in the body).
1761
+ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page guarded), `queryBounded` (server-side pagination — one call walks the cursor until `limit` TOTAL records, default 500 / max 2500, with a `hasMore` flag; the MCP `query` surface, routed by `invoke`), `queryAll` (collect → `T[]` — the unbounded raw walk), `count`, `get` (id → record; `item: null` → `NotFoundError`), `create` / `update` / `delete` (where the vendor defines them — mutations take a `MutationOpts` bag: `{ dryRun, lastModifiedDate?, maxRetries? }` (issue #105: `maxRetries` overrides the configured retry budget for every request the mutation issues; `0` = exactly one send per request)), plus the helper floor where the plan declares it: `resolve` (all 121), `search` (40 — bounded contains on the resource text field), `getContext` (5 workflow resources), `findBy<Field>` (3), `createIfNotExists` (14). Entity metadata on every resource: `getEntityInfo`, `getFieldInfo`, `getUdfInfo` (UDF-capable 16). Parent-scoped children take `(parentId, id)` on the CRUD methods and require `parentId` on `createIfNotExists` (the scoped create POSTs the scoped collection; the dedup query stays global); `query`/`count`/`resolve`/`search` stay global (narrow with a `parentField` filter). Scoped `update` PATCHes the collection (the vendor keys the record by the id in the body).
1517
1762
 
1518
1763
  | resource key | apiEntity | kind | tier | parent | standard ops present | special ops | mcpTool |
1519
1764
  |--------------|-----------|------|------|--------|----------------------|-------------|---------|
@@ -2088,6 +2333,64 @@ own. A match that `get(id)` would return can be invisible to the lookup, so a
2088
2333
  missed dedup can issue a duplicate create; treat dedup there as best-effort
2089
2334
  until the zone is re-verified.
2090
2335
 
2336
+ ### 11.8a Structured `zoneFacts.quirks` entries (issue #253)
2337
+
2338
+ The per-resource `quirks` on the `zoneFacts` block (§11.7) is structured, not
2339
+ free text — a `string[]` of campaign prose (dates, tenant names, operator
2340
+ advice) cannot ride to a model, but a clean statement + a filterable scope
2341
+ can. Each entry (`StructuredQuirk`):
2342
+
2343
+ - `kind` — the closed 13-set (unknown kinds fail `capabilities:check`, clause
2344
+ 23, listing the valid set): `not-persisted` · `blind-surface` ·
2345
+ `integer-picklist` · `wire-type` · `required-in-body` · `not-in-body` ·
2346
+ `parent-scoped` · `field-constraint` · `vendor-managed-side-effect` ·
2347
+ `cascade-delete` · `access-denied` · `unknown-field-noop` · `capability-note`
2348
+ — four of the set are extensions forced by specific migrated strings:
2349
+ `not-in-body` (the scoped-path-vs-body distinction), `field-constraint`
2350
+ (live-verified VALUE constraints), `unknown-field-noop` (the vendor's
2351
+ silent 200 on unknown fields), and `capability-note` (affirmative
2352
+ capabilities).
2353
+ Adding a kind later = one-line extension of the clause's `QUIRK_KINDS` + a
2354
+ plan comment citing the issue; the clause id stays stable.
2355
+ - `ops` — the ops the fact applies to: the closed set `{count, create, query,
2356
+ update, delete, all}` plus the host's own plan `specialOps` (membership
2357
+ against the plan — the set never hard-codes what the plan already declares).
2358
+ A server about to execute `billing_codes.count` keeps exactly the entries
2359
+ whose `ops` include `count`. `archive` is deliberately not in the set (no
2360
+ archive verb in the plan's op vocabulary).
2361
+ - `fields` — optional; the specific wire/model fields the fact constrains
2362
+ (no membership check — names like `internalBillingCodeID` are by definition
2363
+ outside the vendored spec).
2364
+ - `statement` — the clean model-facing sentence: tenant-neutral, date-free,
2365
+ instruction-free.
2366
+ - `evidence` — the verbatim campaign string (the provenance home; frozen and
2367
+ exempt from the hygiene gate — the same standing as `vendorEvidence` on the
2368
+ same surface).
2369
+
2370
+ **The clause 23 hygiene gate**
2371
+ (`scripts/lib/checks/clause23-structured-quirks.mjs`): a build clause that
2372
+ fails when a `statement` carries one of the enforced provenance pattern
2373
+ classes — clause 15's `PROVENANCE_PATTERNS` (campaign markers, tenant ids,
2374
+ branch names, ISO and short dates, 6+ digit ids, …) plus the #253 overrides
2375
+ (`live-verified` / `live-proven` / `proven` / `unproven`, `S9b`-style run
2376
+ codes, `UI-only`, `ask the operator|admin|vendor`, `australia-nz`, `node
2377
+ rule`, slash dates and month-name dates in both orders, `last`/`next`/`this`
2378
+ + a relative unit, ordinals like `the 21st`, `tenant` + a 5+ digit id,
2379
+ `cleanup`, and zone codes like `ww29` even mid-token) — or when an entry is
2380
+ shape-invalid (unknown kind, an op outside the closed set, an empty
2381
+ statement/evidence). The gate is a PATTERN FLOOR: it enforces the listed
2382
+ pattern classes; it is not a natural-language guarantee, so a well-formed
2383
+ date, tenant name or operator instruction in an unlisted shape can slip.
2384
+ `evidence` is NEVER gated. The 2026-10-28 migration replaced the old 11-host
2385
+ `string[]` in place: 28 raw strings → 28 structured entries, every old
2386
+ string preserved verbatim as its entry's `evidence`.
2387
+
2388
+ **Consumer contract** (served via `relationshipMap(k).zoneFacts.quirks`,
2389
+ §9.1): filter entries on `ops`, feed `entry.statement` to the model, and
2390
+ treat `entry.evidence` with the same trust you already apply to
2391
+ `vendorEvidence` on this surface (plan-grade provenance, not model-facing
2392
+ prose). The old `string[]` shape is gone.
2393
+
2091
2394
  ### 11.9 Delete-boundary awareness + the one-call recovery (issue #9)
2092
2395
 
2093
2396
  The §11.1 wall (7 live-verified 405 endpoints) is now a **spec-derived
@@ -2238,38 +2541,84 @@ budget or guessing:
2238
2541
 
2239
2542
  ### 11.12 Zone-fact create guidance (issue #12)
2240
2543
 
2241
- **`operations.createGuidance`** (`{ resources? }`) serves the plan's zone
2242
- facts per child resource — ZERO vendor calls: the `createOp` key, the
2243
- parent-scoping, the live-proven prefix, the `unproven` flag, and the
2244
- blocker: `{ kind, message, vendorEvidence? }`. The backing plan table is the
2245
- 94-resource `zoneFacts.resources`; the 92 create-guidance rows (the 2
2246
- remaining entries — `billing_codes` / `resources`, the blind no-create
2247
- tables — carry zone quirks only) are drift-guarded by clause 15. The stage-11
2248
- live write campaign's decoded knowledge: 92 create entities with the EXACT required-field sets and
2249
- verbatim vendor error evidence (the 500 field-validation ladders, §11.6),
2250
- the blind FK walls (`referenceField` naming the reference table that is
2251
- blind for this API user — no valid id can be read back), and the 9th kind
2252
- `unknown` for the wall-adjacent rows (e.g. `ticket_checklist_libraries`
2253
- W-A7 drift — the 200 that is not the specced envelope). An agent that reads
2254
- the row before its create attempt converts a multi-iteration vendor ladder
2255
- into one predicted-failure-free attempt.
2544
+ **`operations.createGuidance`** (`{ resources?, kind? }`) serves the plan's zone
2545
+ facts per create op — ZERO vendor calls: the `createOp` key, the
2546
+ parent-scoping, the live-proven prefix, the `unproven` flag, the blocker
2547
+ `{ kind, message, remedy, referenceField?, unsatisfiableViaApi?, requires? }`,
2548
+ the exact vendor text in `vendorEvidence`, and the spec-derived
2549
+ `deleteBoundary`.
2550
+
2551
+ **Row counts.** The backing plan table `zoneFacts.resources` has 99 entries;
2552
+ 97 of them carry a `createGuidance` row — exactly one per plan create op, set
2553
+ equality guarded by clause 15 — and the other 2 (`billing_codes`,
2554
+ `resources`, the blind no-create tables) carry zone quirks only. Earlier
2555
+ revisions of this section quoted 92 or 94; the counts are 99 entries / 97 rows.
2556
+ `counts.total` is 97 and `counts.byKind` sums to it.
2557
+
2558
+ **Model-facing by construction (issue #246).** `blocker.message` and
2559
+ `blocker.remedy` are written for the consumer of the guidance: they carry no
2560
+ campaign markers, branch names, W-codes, dates, tenant record ids or
2561
+ maintainer instructions. Anything of that kind that is worth keeping lives in
2562
+ `vendorEvidence` (entries prefixed `maintainer provenance`), next to the
2563
+ verbatim vendor text. Clause 15 fails the build when a message or remedy
2564
+ matches a provenance pattern. A consumer that renders a blocker can therefore
2565
+ serve `message`/`remedy` as they are.
2566
+
2567
+ **Blocker kinds.** The closed set is `ok`, `blind-reference`, `blind-parent`,
2568
+ `picklist-unexposed`, `undocumented-ladder`, `resource-role-pair`,
2569
+ `delete-absent`, `non-mutable` and `unknown`.
2570
+
2571
+ - `delete-absent` is **not** a create blocker. A resource with no vendor
2572
+ DELETE created fine; that is the delete boundary (`deleteBoundary.available:
2573
+ false`, plus the `metadata.deleteBoundary` stamp on the create op). The
2574
+ eight rows that carried the kind (`tickets`, `companies`, `phases`,
2575
+ `company_notes`, ...) are now kind `ok` with the boundary on
2576
+ `deleteBoundary`; the kind is reserved (still accepted by the `kind` filter,
2577
+ 0 rows) and clause 15 fails a row that carries it.
2578
+ - `non-mutable` **is** a create blocker: the vendor refuses the create write
2579
+ itself for the entity. No row carries it today.
2580
+ - `blind-reference` rows always name `referenceField` (the field whose
2581
+ referenced table is blind for this API user), including the contract
2582
+ billing-rule / exclusion / service / bundle rows.
2583
+ - `blocker.unsatisfiableViaApi: true` is the structured flag for creates the
2584
+ API cannot satisfy for any input: exactly the `picklist-unexposed` rows (the
2585
+ attachment `Type` picklist) and the `undocumented-ladder` row
2586
+ (`opportunities`). Clause 15 holds the flag and the kind in step.
2587
+
2588
+ **Where to read the company (issue #246).** Some resources have no `companyID`
2589
+ of their own. `row.parentCompany?: { via, resource, field }` says which parent
2590
+ record carries it: `tasks` serves `{ via: 'projectID', resource: 'projects',
2591
+ field: 'companyID' }` (read the project, then its `companyID`). The hint is
2592
+ derived from the vendored spec (a resource with no `companyID` whose scoping
2593
+ parent field points at a parent that has one) and clause 15 requires the plan
2594
+ row to equal that derivation, so it cannot drift. A resource with its own
2595
+ `companyID` carries no hint.
2256
2596
 
2257
2597
  **Which required set wins (issue #219 N4).** Three sources answer "what does
2258
2598
  this create really require", at different coverage: the live-proven prefix
2259
- (92 rows — the fields a live create actually demanded, in vendor-demand
2260
- order), the hand-authored zone set `createRequiredSet` (6 resources), and the
2261
- vendor field catalog's `isRequired` (the spec opinion — wrong for 68 of the
2262
- 75 resources that carry a proven prefix). The precedence is FIXED:
2263
- **live-proven > zone > spec**, and the source is named on the result.
2264
- `effectiveRequiredFields(record, zoneFacts)` in `node-autotask/operations` is
2265
- the only place in the SDK that compares the three; every
2266
- `operations.createGuidance` row carries its result as
2267
- `requiredFields: { fields, from: 'live-proven' | 'zone' | 'spec' }`
2268
- (`contacts.create` → the four live-proven fields, not the empty spec list).
2269
- A value-conditioned prefix (e.g. `contracts.create`'s type-1
2270
- `estimatedRevenue`/`estimatedHours`/`estimatedCost`) is returned whole with
2271
- `from: 'live-proven'` — the condition is typed on the row (see the next
2272
- paragraph), so read it before treating the list as unconditional.
2599
+ (the fields a live create actually demanded, in vendor-demand order), the
2600
+ hand-authored zone set `createRequiredSet`, and the vendor field catalog's
2601
+ `isRequired` (the spec opinion, wrong for most resources that carry a proven
2602
+ prefix). The precedence is FIXED: **live-proven > zone > spec**, and the source
2603
+ is named on the result. `effectiveRequiredFields(record, zoneFacts)` in
2604
+ `node-autotask/operations` is the only place in the SDK that compares the
2605
+ three; every `operations.createGuidance` row carries its result as
2606
+ `requiredFields: { fields, from, unproven, conditional? }`
2607
+ (`contacts.create` returns the four live-proven fields, not the empty spec list).
2608
+
2609
+ - `fields` is the **unconditional** set. A field the row gates on a picklist
2610
+ value (`requiresByValue`) is removed from it: `contracts.create` returns
2611
+ `status, contractType, contractName, startDate, endDate,
2612
+ timeReportingRequiresStartAndStopTimes, companyID`, and
2613
+ `estimatedRevenue` / `estimatedHours` / `estimatedCost` appear only in
2614
+ `conditional` (contractType 1 needs all three, 3/4/6/8 need
2615
+ `estimatedRevenue`). **Changed in 0.34.0:** the type-1 triple was previously
2616
+ returned inside `fields`.
2617
+ - `conditional?` is the row's `requiresByValue`, copied as typed gates; it is
2618
+ absent when the row declares none.
2619
+ - `unproven` is `true` when no live create is known to succeed with the set:
2620
+ the plan row is unproven (the create still failed after the prefix), or the
2621
+ set is only the vendor spec's opinion. It is additive on the result.
2273
2622
 
2274
2623
  **Structured create requirements (issue #219 N3).** The blocker channel is a
2275
2624
  CLASS of wall, not a statement of what the vendor wants; and the campaign
@@ -2305,6 +2654,32 @@ clause that stops this fact from being lost a second time. The create op's
2305
2654
  registry record carries the same typed block as `metadata.createRequirements`
2306
2655
  (the `autotask_describe` channel).
2307
2656
 
2657
+ ### 11.12a Per-operation caveats (issue #249)
2658
+
2659
+ An operation record may carry `metadata.caveats`, an optional list of
2660
+ structured facts about the operation itself, served wherever operation
2661
+ metadata is (the `autotask_describe` channel, `capabilities.json`,
2662
+ `node-autotask/capabilities`). One row shape over a CLOSED kind set:
2663
+
2664
+ ```ts
2665
+ { kind: 'not-persisted'; fields?: string[]; note: string; remedyOp?: string }
2666
+ ```
2667
+
2668
+ - `not-persisted`: the op accepts the listed `fields` but the vendor does not
2669
+ store them. `fields` is required for this kind; `remedyOp` names the plan
2670
+ operation that does what this one cannot.
2671
+ - First rows: `time_entries.update` and `ticket_charges.update` declare
2672
+ `{ kind: 'not-persisted', fields: ['ticketID'], remedyOp:
2673
+ '<resource>.moveToTicket' }`.
2674
+ - The kind set is closed at build time: `scripts/generate-capabilities.mjs`
2675
+ throws on an unknown kind or a malformed row, and gate clause 22 also checks
2676
+ that every named field exists on the resource model and every `remedyOp` is
2677
+ a plan operation.
2678
+ - Purely additive: `metadata.usage`, `metadata.pointerNote` and every other
2679
+ served field are unchanged, and a record without caveats has no `caveats`
2680
+ key. Unlike `pointerNote`, caveats ride the describe channel only (they are
2681
+ not attached to error details).
2682
+
2308
2683
  ### 11.13 Posted state from billing items (W-D3), `getPosted` / `getUnposted` / `postedWithin`, and drifted-create marker recovery (issues #10/#13/#237)
2309
2684
 
2310
2685
  - **W-D3 (live, ww29, re-verified 2026-10-27)** — this zone's TimeEntry entity
@@ -2325,12 +2700,14 @@ registry record carries the same typed block as `metadata.createRequirements`
2325
2700
  condition is ever sent. The join is one `BillingItems` query per 250 ids;
2326
2701
  the entry read is capped at 500, so a call costs at most 1 read + 2 joins
2327
2702
  and a window with more than 500 entries returns the first 500 with
2328
- `hasMore` / `truncated` true. A 429 or vendor fault propagates as a typed
2703
+ `hasMore` / `truncated` true and a `nextCursor` to continue from (or use
2704
+ `countOnly` for the window's totals; see the paging bullet below). A 429 or vendor fault propagates as a typed
2329
2705
  error. The API user therefore needs BillingItems read permission; a permission fault propagates as a typed error.
2330
2706
  - **`time_entries.getPosted(opts?)` / `getUnposted(opts?)`** — `opts` is
2331
- `{ limit?, ticketID?, dateFrom?, dateTo? }` (`dateFrom`/`dateTo`:
2707
+ `{ limit?, ticketID?, dateFrom?, dateTo?, cursor?, countOnly?, maxVendorCalls? }`
2708
+ (the paging options: see the paging bullet below; `dateFrom`/`dateTo`:
2332
2709
  inclusive `dateWorked` days, `YYYY-MM-DD`, never tz-shifted). They return
2333
- `{ items, hasMore, window, approvedCount, unapprovedCount }` (a breaking
2710
+ `{ items, hasMore, window, nextCursor, unconfirmed, approvedCount, unapprovedCount }` (a breaking
2334
2711
  change from the bare `TimeEntry[]`, 0.32.0): `getPosted` items are entries
2335
2712
  a billing item references; `getUnposted` items are entries none references.
2336
2713
  `approvedCount` / `unapprovedCount` are informational (items with / without
@@ -2343,7 +2720,7 @@ registry record carries the same typed block as `metadata.createRequirements`
2343
2720
  guard forbids Date positionals), one bounded read (`limit` 1-500, default
2344
2721
  500 — the hard cap, issue #128: a larger window needs `time_entries.query`
2345
2722
  with the same `dateWorked` filter, its 2500-row walk cap) returning
2346
- `{ items, postedCount, unpostedCount, window, truncated }`; the counts come
2723
+ `{ items, postedCount, unpostedCount, window, nextCursor, unconfirmed, truncated }`; the counts come
2347
2724
  from the billing-item join (any billing item referencing the entry), `opts.posted` narrows client-side after the join
2348
2725
  (true = posted only, false = unposted only, omitted = both); the counts still split every entry read (before the narrowing), so `items.length` can differ from their sum and `ticketID` /
2349
2726
  `resourceID` narrow the read. Live evidence for the visibility class
@@ -2365,6 +2742,57 @@ registry record carries the same typed block as `metadata.createRequirements`
2365
2742
  but, since 0.24.0, it never shifts the bounds: `dateWorked` is a date-only
2366
2743
  value at 00:00 UTC, so `from`/`to` are the days (see §7.11). The same `tz`
2367
2744
  opt is on every date-window op — see §11.22.
2745
+ - **Paging past 500 entries and whole-window totals (issue #242)** — all four
2746
+ ops (`getPosted`, `getUnposted`, `postedWithin`, `dateWorkedWindow`) take
2747
+ three more options:
2748
+ - `cursor?: string` — every rows result now carries `nextCursor: string | null`
2749
+ (the vendor's next-page cursor for the entry read). Pass it back as
2750
+ `opts.cursor` with the **same** window and filters to read the next page;
2751
+ each call stays one entry page (the vendor's continuation page, up to 500
2752
+ entries; `limit` sizes the first page only) plus at most 2 joins. Loop
2753
+ until `nextCursor` is `null` to enumerate a busy window. An off-origin
2754
+ cursor, or a same-origin cursor whose path is not the TimeEntries
2755
+ continuation (`/V1.0/TimeEntries/query/next`), or a cursor longer than
2756
+ 2,048 characters, is refused (`RESPONSE_REJECTED`) before any request. Every rows result also carries
2757
+ `unconfirmed: number`: the entries on this page whose billing-item join
2758
+ page was truncated. They are withheld from `items` (getPosted /
2759
+ getUnposted) and from both counts (postedWithin / dateWorkedWindow, which
2760
+ also withhold them from `items` when `posted` is set; with `posted`
2761
+ omitted they stay in `items`, unclassified), so a cursor loop does not
2762
+ see them as posted or unposted; `unconfirmed` is 0 normally, and when it is
2763
+ above 0 `hasMore` / `truncated` is also true. Sum it across a loop to know
2764
+ how many entries went unclassified (or use `countOnly`, which refuses
2765
+ instead).
2766
+ - `countOnly?: true` — returns only the whole window's totals:
2767
+ `{ window, total, postedCount, unpostedCount, vendorCalls, estimate, budget }`
2768
+ (typed overloads select `TimeEntriesPostedCountResult`). One
2769
+ `TimeEntries/query/count` call sizes the window; then every entry page is
2770
+ read ids-only (`IncludeFields: ['id']`, following the vendor cursor) and
2771
+ joined at 250 ids per join, and the window is counted again after the walk.
2772
+ `total` = `postedCount + unpostedCount` (de-duplicated by id). The call
2773
+ fails with `INVALID_RESPONSE` (`details: { reason: 'count_mismatch',
2774
+ streamed, counted, recounted }`) when the distinct-id total equals NEITHER
2775
+ the count before the walk NOR the count after it (a page repeated one entry
2776
+ and dropped another); a total equal to either count is accepted, so a
2777
+ window that grows or shrinks by the time the walk ends still succeeds, and
2778
+ one that changes mid-walk can fail or pass depending on timing: retry.
2779
+ `limit` and `posted` do not apply; `cursor` is rejected with `countOnly`
2780
+ (CONFIG_ERROR).
2781
+ - `maxVendorCalls?: number` — the `countOnly` budget (integer 1-200,
2782
+ default 30). The estimate is `1 + max(1, ceil(n / 500)) + ceil(n / 250) + 1`
2783
+ (the last 1 is the re-count) from the count `n` (the live week of 1,321 entries costs 11). The budget
2784
+ and `vendorCalls` count logical requests; transport retries of a 429 /
2785
+ 5xx (up to `maxRetries` each) are not counted. Over budget
2786
+ the call throws `BUDGET_EXCEEDED` (category `policy`, `details: { estimate,
2787
+ budget, total, issued }`, a `suggestedAction` naming a narrower window or
2788
+ `maxVendorCalls >= estimate`) after the count call and before any page
2789
+ read. The same code stops a walk that needs more calls than estimated
2790
+ (more entries than counted, or an extra page such as an empty
2791
+ continuation page) before the call that would exceed the budget, the
2792
+ closing re-count included; its `suggestedAction` names a narrower window
2793
+ or a `maxVendorCalls` above the budget, never `>= estimate`. A
2794
+ truncated join throws `RESOLUTION_TRUNCATED`, and a 429 propagates as
2795
+ `RATE_LIMITED`: the total is complete or not returned.
2368
2796
  - **Drifted-create marker recovery (issue #13)** — where the plan pins a
2369
2797
  `responseDrift` row (the pinned resource: `ticket_checklist_libraries`,
2370
2798
  the W-A7 drift: the vendor answers 200 with a body that is not
@@ -2462,10 +2890,17 @@ there is no second entity-type table.
2462
2890
  parallel field system, and the served row states it rather than leaving it
2463
2891
  inferred. With the `udf` include, the row's `udf` list holds the UDF
2464
2892
  DEFINITIONS the vendor's `entityInformation/userDefinedFields` channel
2465
- returns — `name`, `label`, `type`, `isRequired`, `isReadOnly`, `isPickList`,
2466
- `isReference`, `referenceEntityType`, `picklistValues` (the values ride the
2467
- `labels: true` opt, zero extra wire), `defaultValue`. A UDF definition never
2468
- appears in the plan artifact (it is tenant-specific). The row also carries
2893
+ returns, projected to `name`, `label` (when non-empty), `type`, `isRequired`,
2894
+ `isReadOnly`, `filterable: 'udf-marker'`, `isPickList`, `maxLength`,
2895
+ `referenceEntityType`, `targetResource`, `targetEnumerable`, and
2896
+ `picklistValues` (the values ride the `labels: true` opt, zero extra wire;
2897
+ present only for a picklist that exposes values). The vendor's `isReference`
2898
+ and `defaultValue` are not projected. A UDF definition never
2899
+ appears in the plan artifact (it is tenant-specific). The row's `udfCapable`
2900
+ is the plan resource flag (16 resources), which every capability record also
2901
+ carries as `CapabilityRecord.udfCapable` (issue #254); every vendor entity has
2902
+ a `getUdfInfo` catalog endpoint, so that op's presence does not make a
2903
+ resource UDF-capable. The row also carries
2469
2904
  `udfFilter: 'udf-marker'` and `udfWriteShape: 'merge'` exactly when it is
2470
2905
  `udfCapable`: a filter on a UDF NAME is accepted by the vendor only with the
2471
2906
  `udf: true` marker, so a UDF field row serves `filterable: 'udf-marker'` and