node-autotask 0.34.0 → 0.37.1

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 +58 -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 +3097 -1344
  8. package/capabilities.schema.json +4 -0
  9. package/dist/capabilities.cjs +2 -2
  10. package/dist/capabilities.d.cts +2 -0
  11. package/dist/capabilities.d.ts +2 -0
  12. package/dist/capabilities.js +1 -1
  13. package/dist/{chunk-P6YN2POI.cjs → chunk-25VGFT5M.cjs} +143 -143
  14. package/dist/{chunk-EZKM4HQ6.js → chunk-7IADHNBZ.js} +8486 -3122
  15. package/dist/{chunk-RDFL5QYE.js → chunk-7WI7FBPB.js} +12 -12
  16. package/dist/{chunk-FOOHJBPP.cjs → chunk-HBYKTXN3.cjs} +3096 -1343
  17. package/dist/{chunk-4MUBUSTL.js → chunk-KQ6HMBB4.js} +3096 -1343
  18. package/dist/{chunk-DKJP73FX.cjs → chunk-PFFW4DIK.cjs} +4657 -9597
  19. package/dist/{chunk-6ADFCU3I.js → chunk-PFJAMFUL.js} +4456 -9396
  20. package/dist/{chunk-GDQ4QM7Q.cjs → chunk-TBFXRNNE.cjs} +8452 -3088
  21. package/dist/{index-ByMU8IC2.d.ts → index-BDqw377y.d.ts} +117 -14
  22. package/dist/{index-Dx4-zpDo.d.cts → index-CqepIQfp.d.cts} +117 -14
  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 +828 -137
  28. package/dist/operations/index.d.cts +145 -5
  29. package/dist/operations/index.d.ts +145 -5
  30. package/dist/operations/index.js +697 -6
  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 +331 -13
  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
@@ -619,7 +620,11 @@ without the username). Consequences:
619
620
  evicted-then-recreated tenant on the same host hits the same state).
620
621
  `store` injects a custom `CircuitBreakerStore` (the same get/set/delete
621
622
  shape as `CooldownStore`, over the richer per-key state
622
- `{ failures, openedAt, successes, probes }`).
623
+ `{ failures, openedAt, successes, probes, windowOpenedAt? }`;
624
+ `windowOpenedAt` is the open-window identity a probe's settlement is
625
+ pinned to — persist it as-is, and keep every process sharing a store on
626
+ the same version, since a build that re-arms an entry without it leaves
627
+ newer pinned probes voided with their slot unreleased).
623
628
 
624
629
  **States (timer-free — derived from `Date.now()` lazily at each request,
625
630
  exactly like the #6 cooldown):** `closed` (no entry / nothing armed — a
@@ -1106,13 +1111,13 @@ be cross-mode-refused.
1106
1111
  and the `confirm === <op key>` gate (all 54 deletes; the 3 approval-gated
1107
1112
  writes — inside `autotask_write` too) apply exactly as in `invoke`.
1108
1113
  Pinned by the full-surface suite (`test/operations/invoke/modes.test.ts`:
1109
- all 1275 ops × 3 modes, zero wire).
1114
+ all 1281 ops × 3 modes, zero wire).
1110
1115
 
1111
1116
  **Harness approval policy (recommended).** Require approval on every tool
1112
1117
  whose MCP annotation is `readOnlyHint !== true` — today exactly **267
1113
1118
  tools**: the 265 dedicated write/delete tools (209 `effect=write` + 56
1114
1119
  `effect=destructive`, every one of them has a dedicated tool) +
1115
- `autotask_write` + `autotask_delete`. The remaining **634 tools** (the 621 dedicated read tools + the 10 core read tools + `autotask_read` +
1120
+ `autotask_write` + `autotask_delete`. The remaining **635 tools** (the 622 dedicated read tools + the 10 core read tools + `autotask_read` +
1116
1121
  `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
1117
1122
  and are safe ungated: every op they can route is `effect=read` (zero
1118
1123
  mutating requests — pinned by the test inventory, §6 of the split design).
@@ -1563,10 +1568,201 @@ The secondary read re-applies the open filter client-side, and `limit` is not
1563
1568
  validated (it does not apply). With `scope` `primary`/`secondary` only that side is
1564
1569
  read and `both` is 0.
1565
1570
 
1571
+ ### 7.14 `revenueRollup(client, opts)` — revenue by dimension, budgeted (issues #234, #235, #236)
1572
+
1573
+ One read call answers "revenue for Recurring Service + Fixed Price contracts
1574
+ over a window, by account vertical": the registry op `operations.revenueRollup`
1575
+ (MCP tool `autotask_revenue_rollup`, tier extended).
1576
+
1577
+ - **Scope** (exactly one, REQUIRED — no scope is refused with zero wire):
1578
+ `companyIDs` | `ownerResourceID` (resolved to company ids by one projected
1579
+ `companies` query) | `contractIDs` | `tenantWide: true`. A company scope
1580
+ filters on the LINE's `companyID` (attribution is by the line's company).
1581
+ - **Window**: `dateFrom` / `dateTo` (`YYYY-MM-DD`, both REQUIRED, inclusive,
1582
+ whole UTC days, at most 36 months), invoice-date basis.
1583
+ The window is whole calendar days sent to the vendor as bare `YYYY-MM-DD`
1584
+ bounds; that was verified on a tenant ahead of UTC only, and tenants behind
1585
+ UTC are unverified.
1586
+ - **Revenue** = Σ `billing_items.internalCurrencyTotalAmount` (ex-tax, internal
1587
+ currency) over the lines of NON-VOIDED invoices (`isVoided eq false`
1588
+ server-side) in the window, folded in integer cents.
1589
+ `includedValue` = Σ(`internalCurrencyExtendedPrice` − total) is
1590
+ informational and never added to revenue. Credits (negative lines) are
1591
+ netted and also reported per row. A line whose `internalCurrencyTotalAmount`
1592
+ / `internalCurrencyExtendedPrice` is missing or null falls back to the
1593
+ local-currency `totalAmount` / `extendedPrice` (as contract profitability
1594
+ does), everywhere those fields are read (revenue, `includedValue`, the
1595
+ non-chargeable class and value); never silently: one
1596
+ `coverage.blindSurfaces` entry gives the count of such lines.
1597
+ - **`contractTypes`** (ids or exact labels) default to the live picklist
1598
+ labels that START with `Recurring` or `Fixed` (case-insensitive,
1599
+ `/^\s*(recurring|fixed)\b/i`: `Non-Recurring` does not match); the resolved map is echoed in
1600
+ `coverage.contractTypeMap`. No match is a `CONFIG_ERROR` listing the live
1601
+ labels and ids. `contractCategories` filters the same way, and when given
1602
+ its resolved map is echoed in `coverage.contractCategoryMap` (`{ "<id>":
1603
+ label }`, absent otherwise). A label shared by several live picklist ids
1604
+ selects every one of them.
1605
+ - **`groupBy`** (default `['vertical', 'contractType']`): `vertical`
1606
+ (`companies.marketSegmentID`), `contractType`, `contractCategory`,
1607
+ `company`, `month` (the invoice month), `billingItemKind`, and the join
1608
+ dimensions (#235):
1609
+ - `contract` — `{ id, name }` (`contractName` joins the contract fetch).
1610
+ - `lineOfBusiness` — per billing line (`organizationalLevelAssociationID`;
1611
+ no contract-pointer fallback): three batch `id in` fetches (associations,
1612
+ level1s, level2s) give `{ id: associationId, name: level2Name (the Line of
1613
+ Business), division: level1Name }`. Lines without a pointer form their own
1614
+ row `{ id: null, label: 'no LOB assigned' }`, and
1615
+ `coverage.lineOfBusiness = { noPointer: { amount, lines }, note }` carries
1616
+ their amount (LOB figures are floors). A row's `contracts` is "contracts
1617
+ with at least one line in this LOB", so per-LOB counts can sum to more than
1618
+ `totals.contracts`.
1619
+ - `resource` — labour lines only (`billingItemType` Labor / Labor
1620
+ Adjustment, or the FK fallback): `timeEntryID` → time entry → resource,
1621
+ `{ id: resourceID, name: 'First Last' }`. Non-labour lines, and labour
1622
+ lines with no time entry (e.g. an adjustment), are `{ id: null, label: 'n/a' }`.
1623
+ - `billingCode` — `billingCodeID` → billing code `{ id, name }`; a line
1624
+ without one is `{ id: null, label: 'none' }`.
1625
+
1626
+ Each row key is `{ id, label?, name?, division? }`: picklist text in
1627
+ `label`; vendor entity text (untrusted — company, contract, LOB level2,
1628
+ resource and billing-code names) in `name`, and the LOB division (level1
1629
+ name, also untrusted) in `division`. Missing values get their own row
1630
+ (`unassigned`, `uncategorised`, `no LOB assigned`, `n/a`, `none`); a value
1631
+ the picklist does not serve stays id-only and is listed in
1632
+ `coverage.blindSurfaces`. A join id the `id in` fetch does not return is
1633
+ never guessed: the line goes to `coverage.excluded.unresolvedAssociations`
1634
+ (association or its level1/level2), `unresolvedTimeEntries` (time entry or
1635
+ its resource) or `unresolvedBillingCodes`, with its amount. These buckets
1636
+ (and `coverage.lineOfBusiness`) appear only when their dimension is grouped;
1637
+ the line projection grows by `organizationalLevelAssociationID`,
1638
+ `timeEntryID` / the type fields and `billingCodeID` the same way.
1639
+ - **Rows** are sorted by revenue descending, then ids; `top` (default 100,
1640
+ max 100) keeps the head and folds the tail into `rowsTruncated`. `totals`
1641
+ always cover every row.
1642
+ - **`coverage.excluded`**: voided invoices (count + header amount; under a
1643
+ company / owner scope only the voided invoices whose HEADER `companyID` is
1644
+ in scope; omitted for a `contractIDs` scope, since an invoice header cannot
1645
+ be attributed to a contract, and noted in `coverage.notes`), uncontracted
1646
+ lines, other contract types (per label), other contract categories (per
1647
+ label; a label shared by several live ids is keyed `label (id)`, so ids
1648
+ are never merged) and
1649
+ unresolved contracts / companies (ids the `id in` fetch did not return; an
1650
+ unresolved contract is never counted as uncontracted), each with an amount
1651
+ and a line count. Every line is in exactly one place, so `totals.revenue` +
1652
+ Σ the excluded line buckets = Σ the lines fetched (lines are de-duplicated
1653
+ by id before the fold).
1654
+ - **`coverage.zeroTotalInvoices`** `{ count, amount, lines }` is an
1655
+ informational overlay, NOT an excluded bucket: the zero-total invoices in
1656
+ scope (tenant-wide: all; company / owner scope: header company in scope or
1657
+ carrying an in-scope line; `contractIDs`: carrying a scoped line) and the
1658
+ amount / count of their lines. Those lines are still folded into rows or the
1659
+ excluded buckets.
1660
+ - **`includeNonChargeable`** (#236, default `false`; when `false` the call,
1661
+ its wire and its output are byte-identical to leaving it out): adds
1662
+ `nonChargeable: { contractCovered: { value, hours, lines }, nonBillable:
1663
+ { value, hours, lines } }` to every row and to `totals`. It is NEVER added to
1664
+ `revenue` / `totals.revenue`. Classes per line: non-billable =
1665
+ `nonBillable` true; contract-covered = billable with
1666
+ `internalCurrencyExtendedPrice` > 0 and `internalCurrencyTotalAmount` = 0.
1667
+ `value` = Σ `internalCurrencyExtendedPrice` (integer cents); `hours` = Σ
1668
+ `quantity` on labour lines, Labor Adjustment (type 2) included (it adjusts
1669
+ labour hours, and `billingLineType` already classes it as labour). The
1670
+ invoiced part comes from the revenue line stream (+ `nonBillable`,
1671
+ `quantity` and the type fields); the uninvoiced part is a second unsliced
1672
+ stream `invoiceID notExist` with `itemDate` in the window (+ the same
1673
+ scope). Uninvoiced lines have no invoice date, so they are windowed and
1674
+ month-keyed on `itemDate` (stated in `coverage.notes`). They are keyed
1675
+ through the same joins (one fetch over both streams, so only ids the
1676
+ invoiced lines did not already need are added); uninvoiced lines that are
1677
+ uncontracted or of another contract type / category are outside the
1678
+ request. A non-chargeable line, invoiced or uninvoiced, whose row cannot be
1679
+ resolved (an unresolved contract / company or a join miss) goes to
1680
+ `coverage.excluded.unresolvedNonChargeable` (amount = Σ value); an invoiced
1681
+ one is also in its revenue excluded bucket. The default `maxVendorCalls` becomes 150 (an explicit
1682
+ value wins; cap 200).
1683
+ - **`coverage.reconciliation`**: header `invoiceTotal` vs Σ line `totalAmount`
1684
+ per invoice (tolerance 0 cents; `totalAmount`, so a multi-currency tenant
1685
+ gives no false delta). Tenant-wide it covers every invoice; a company scope
1686
+ covers the invoices whose header company is in scope; a `contractIDs`
1687
+ scope skips it (noted).
1688
+ - **Budget** (`maxVendorCalls`, default 90, max 200): every vendor call goes
1689
+ through a counting wrapper. Before any stream the op counts the window's
1690
+ non-voided invoices and the invoiced lines (`itemDate` in the window, a
1691
+ proxy for the lines of those invoices) and estimates
1692
+ `calls so far + 1 (voided; not for a contractIDs scope) + invoice pages + line pages (one per 500-invoice
1693
+ chunk + overflow) + one line count per 500-invoice chunk (the per-chunk line gate) + uninvoiced pages (includeNonChargeable: its count is in
1694
+ "calls so far", ≥ 1 page) + each join's id-fetch chunks`. A join's chunks
1695
+ default to `ceil(min(lines + uninvoiced, invoices) / 500)` (contracts,
1696
+ companies, LOB associations, billing codes); time entries use
1697
+ `ceil((lines + uninvoiced) / 500)` (one per labour line, the labour share is
1698
+ unknown pre-walk); LOB level1s / level2s and resources count one chunk each
1699
+ (small org / staff tables). Over budget it REFUSES with `BUDGET_EXCEEDED`
1700
+ (`details: { phase: 'estimate', estimate, budget, suggestions }`, the
1701
+ message names the narrowings that apply: `maxVendorCalls: N` (while N is
1702
+ within the cap), a later `dateFrom`, a scope, and, when raising the budget
1703
+ cannot help (estimate past the cap), `includeNonChargeable: false`, fewer
1704
+ `groupBy` dimensions, `contractIDs`, or a shorter id list (only for a list of
1705
+ more than one id); at least one suggestion is always present. Only a raised
1706
+ `maxVendorCalls` and a later `dateFrom` carry an estimate: the invoice
1707
+ stream is tenant-wide in every scope and the line stream costs at least one
1708
+ page and one count per chunk of 500 of those invoices, so a scope or shape
1709
+ narrowing does not reduce that part, and when the refusal offers one its
1710
+ message says it may still be refused; when nothing narrower exists the
1711
+ only suggestion is the cap, and it says the call will be refused at that
1712
+ estimate even at the cap). A budget
1713
+ below the sizing overhead (the labels, owner query and count calls that run
1714
+ before the estimate exists: at least 4 calls for the default shape, more with
1715
+ `ownerResourceID`, `includeNonChargeable` or more picklist dimensions) is
1716
+ the same refusal (`phase: 'estimate'`), with `estimate` = that minimum and
1717
+ `suggestions: [{ maxVendorCalls }]`; the full estimate is named once the
1718
+ call is sized.
1719
+ Mid-walk the wrapper throws `BUDGET_EXCEEDED` (`phase: 'walk'`) before the
1720
+ call that would exceed the budget. The wrapper counts LOGICAL requests: the
1721
+ transport retries a 429 / 5xx internally (up to `maxRetries` each) below
1722
+ it, and those retries are not counted in the budget, `vendorCalls` or
1723
+ `tenantHourlyShare`. A 429 propagates as `RATE_LIMITED`. These fail as
1724
+ `INVALID_RESPONSE`: a blind gate count (`reason: 'blind_count'`); the invoice
1725
+ stream, each invoice-chunk line stream and the uninvoiced stream
1726
+ (`includeNonChargeable`) whose DISTINCT ids differ from their gate count
1727
+ (`reason: 'count_mismatch'`, `details.stream` names it: a page that repeats
1728
+ one row and drops another is caught; each line chunk is counted on exactly
1729
+ its stream's filter); and a line whose invoice is not among the streamed
1730
+ invoices (`reason: 'orphan_line'`). Retry on a count race (data that changes
1731
+ mid-walk). A repeated voided header is counted once, but a DROPPED voided
1732
+ header is undetectable (there is no voided count call), so
1733
+ `coverage.excluded.voidedInvoices` is a floor under vendor paging drift. Every fault discards the fold: a result is always
1734
+ complete. `coverage.vendorCalls` is the exact logical-request count (joins
1735
+ included; transport retries excluded); ~50
1736
+ calls for a 12-month tenant-wide report on the reference tenant, ~115 with
1737
+ `includeNonChargeable`. `resource` on 12 months tenant-wide adds ~15-25
1738
+ calls and can refuse at the default 90 (the refusal names N).
1739
+ An `ownerResourceID` that owns more than 100 companies is a `CONFIG_ERROR`
1740
+ after at most one page past the 100th id (the resolution stops there).
1741
+ - **Limits**: amounts are rounded to cents per line (`Math.round(x * 100)`)
1742
+ before summing, on the assumption that the vendor sends at most 2 decimals;
1743
+ a 3-decimal amount can drift, and reconciliation (header vs line totals)
1744
+ flags the delta. Non-chargeable `hours` are rounded to hundredths per line
1745
+ before summing. A row that holds only non-chargeable work has revenue 0, so
1746
+ it sorts above rows with negative revenue (net credits). The estimate costs
1747
+ the voided stream as one page and assumes distinct contracts / companies
1748
+ ≤ invoices; a window with more than 500 voided invoices, or (with
1749
+ `includeNonChargeable`) many uninvoiced lines on contracts that never
1750
+ invoiced in the window, can exceed it, and such a call fails mid-walk with
1751
+ `BUDGET_EXCEEDED` (`phase: 'walk'`) rather than returning a partial result.
1752
+
1753
+ ```ts
1754
+ import { revenueRollup } from 'node-autotask/operations';
1755
+ const r = await revenueRollup(client, { tenantWide: true, dateFrom: '2025-07-01', dateTo: '2026-06-30' });
1756
+ // r.rows[0] → { keys: { vertical: { id: 12, label: 'Retail' }, contractType: { id: 7, label: 'Recurring Service' } }, revenue, credits, includedValue, contracts, items }
1757
+ const lob = await revenueRollup(client, { tenantWide: true, dateFrom: '2025-07-01', dateTo: '2026-06-30',
1758
+ contractTypes: ['Recurring Service'], groupBy: ['vertical', 'lineOfBusiness'], includeNonChargeable: true });
1759
+ // lob.rows[i].keys.lineOfBusiness → { id: 7, name: '<level2>', division: '<level1>' } | { id: null, label: 'no LOB assigned' }
1760
+ ```
1761
+
1566
1762
  ## 8. Resource surface (135 resources)
1567
1763
 
1568
1764
  <!-- BEGIN GENERATED:api-per-entity -->
1569
- 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).
1765
+ 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).
1570
1766
 
1571
1767
  | resource key | apiEntity | kind | tier | parent | standard ops present | special ops | mcpTool |
1572
1768
  |--------------|-----------|------|------|--------|----------------------|-------------|---------|
@@ -2141,6 +2337,64 @@ own. A match that `get(id)` would return can be invisible to the lookup, so a
2141
2337
  missed dedup can issue a duplicate create; treat dedup there as best-effort
2142
2338
  until the zone is re-verified.
2143
2339
 
2340
+ ### 11.8a Structured `zoneFacts.quirks` entries (issue #253)
2341
+
2342
+ The per-resource `quirks` on the `zoneFacts` block (§11.7) is structured, not
2343
+ free text — a `string[]` of campaign prose (dates, tenant names, operator
2344
+ advice) cannot ride to a model, but a clean statement + a filterable scope
2345
+ can. Each entry (`StructuredQuirk`):
2346
+
2347
+ - `kind` — the closed 13-set (unknown kinds fail `capabilities:check`, clause
2348
+ 23, listing the valid set): `not-persisted` · `blind-surface` ·
2349
+ `integer-picklist` · `wire-type` · `required-in-body` · `not-in-body` ·
2350
+ `parent-scoped` · `field-constraint` · `vendor-managed-side-effect` ·
2351
+ `cascade-delete` · `access-denied` · `unknown-field-noop` · `capability-note`
2352
+ — four of the set are extensions forced by specific migrated strings:
2353
+ `not-in-body` (the scoped-path-vs-body distinction), `field-constraint`
2354
+ (live-verified VALUE constraints), `unknown-field-noop` (the vendor's
2355
+ silent 200 on unknown fields), and `capability-note` (affirmative
2356
+ capabilities).
2357
+ Adding a kind later = one-line extension of the clause's `QUIRK_KINDS` + a
2358
+ plan comment citing the issue; the clause id stays stable.
2359
+ - `ops` — the ops the fact applies to: the closed set `{count, create, query,
2360
+ update, delete, all}` plus the host's own plan `specialOps` (membership
2361
+ against the plan — the set never hard-codes what the plan already declares).
2362
+ A server about to execute `billing_codes.count` keeps exactly the entries
2363
+ whose `ops` include `count`. `archive` is deliberately not in the set (no
2364
+ archive verb in the plan's op vocabulary).
2365
+ - `fields` — optional; the specific wire/model fields the fact constrains
2366
+ (no membership check — names like `internalBillingCodeID` are by definition
2367
+ outside the vendored spec).
2368
+ - `statement` — the clean model-facing sentence: tenant-neutral, date-free,
2369
+ instruction-free.
2370
+ - `evidence` — the verbatim campaign string (the provenance home; frozen and
2371
+ exempt from the hygiene gate — the same standing as `vendorEvidence` on the
2372
+ same surface).
2373
+
2374
+ **The clause 23 hygiene gate**
2375
+ (`scripts/lib/checks/clause23-structured-quirks.mjs`): a build clause that
2376
+ fails when a `statement` carries one of the enforced provenance pattern
2377
+ classes — clause 15's `PROVENANCE_PATTERNS` (campaign markers, tenant ids,
2378
+ branch names, ISO and short dates, 6+ digit ids, …) plus the #253 overrides
2379
+ (`live-verified` / `live-proven` / `proven` / `unproven`, `S9b`-style run
2380
+ codes, `UI-only`, `ask the operator|admin|vendor`, `australia-nz`, `node
2381
+ rule`, slash dates and month-name dates in both orders, `last`/`next`/`this`
2382
+ + a relative unit, ordinals like `the 21st`, `tenant` + a 5+ digit id,
2383
+ `cleanup`, and zone codes like `ww29` even mid-token) — or when an entry is
2384
+ shape-invalid (unknown kind, an op outside the closed set, an empty
2385
+ statement/evidence). The gate is a PATTERN FLOOR: it enforces the listed
2386
+ pattern classes; it is not a natural-language guarantee, so a well-formed
2387
+ date, tenant name or operator instruction in an unlisted shape can slip.
2388
+ `evidence` is NEVER gated. The 2026-10-28 migration replaced the old 11-host
2389
+ `string[]` in place: 28 raw strings → 28 structured entries, every old
2390
+ string preserved verbatim as its entry's `evidence`.
2391
+
2392
+ **Consumer contract** (served via `relationshipMap(k).zoneFacts.quirks`,
2393
+ §9.1): filter entries on `ops`, feed `entry.statement` to the model, and
2394
+ treat `entry.evidence` with the same trust you already apply to
2395
+ `vendorEvidence` on this surface (plan-grade provenance, not model-facing
2396
+ prose). The old `string[]` shape is gone.
2397
+
2144
2398
  ### 11.9 Delete-boundary awareness + the one-call recovery (issue #9)
2145
2399
 
2146
2400
  The §11.1 wall (7 live-verified 405 endpoints) is now a **spec-derived
@@ -2450,12 +2704,14 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2450
2704
  condition is ever sent. The join is one `BillingItems` query per 250 ids;
2451
2705
  the entry read is capped at 500, so a call costs at most 1 read + 2 joins
2452
2706
  and a window with more than 500 entries returns the first 500 with
2453
- `hasMore` / `truncated` true. A 429 or vendor fault propagates as a typed
2707
+ `hasMore` / `truncated` true and a `nextCursor` to continue from (or use
2708
+ `countOnly` for the window's totals; see the paging bullet below). A 429 or vendor fault propagates as a typed
2454
2709
  error. The API user therefore needs BillingItems read permission; a permission fault propagates as a typed error.
2455
2710
  - **`time_entries.getPosted(opts?)` / `getUnposted(opts?)`** — `opts` is
2456
- `{ limit?, ticketID?, dateFrom?, dateTo? }` (`dateFrom`/`dateTo`:
2711
+ `{ limit?, ticketID?, dateFrom?, dateTo?, cursor?, countOnly?, maxVendorCalls? }`
2712
+ (the paging options: see the paging bullet below; `dateFrom`/`dateTo`:
2457
2713
  inclusive `dateWorked` days, `YYYY-MM-DD`, never tz-shifted). They return
2458
- `{ items, hasMore, window, approvedCount, unapprovedCount }` (a breaking
2714
+ `{ items, hasMore, window, nextCursor, unconfirmed, approvedCount, unapprovedCount }` (a breaking
2459
2715
  change from the bare `TimeEntry[]`, 0.32.0): `getPosted` items are entries
2460
2716
  a billing item references; `getUnposted` items are entries none references.
2461
2717
  `approvedCount` / `unapprovedCount` are informational (items with / without
@@ -2468,7 +2724,7 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2468
2724
  guard forbids Date positionals), one bounded read (`limit` 1-500, default
2469
2725
  500 — the hard cap, issue #128: a larger window needs `time_entries.query`
2470
2726
  with the same `dateWorked` filter, its 2500-row walk cap) returning
2471
- `{ items, postedCount, unpostedCount, window, truncated }`; the counts come
2727
+ `{ items, postedCount, unpostedCount, window, nextCursor, unconfirmed, truncated }`; the counts come
2472
2728
  from the billing-item join (any billing item referencing the entry), `opts.posted` narrows client-side after the join
2473
2729
  (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` /
2474
2730
  `resourceID` narrow the read. Live evidence for the visibility class
@@ -2490,6 +2746,57 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2490
2746
  but, since 0.24.0, it never shifts the bounds: `dateWorked` is a date-only
2491
2747
  value at 00:00 UTC, so `from`/`to` are the days (see §7.11). The same `tz`
2492
2748
  opt is on every date-window op — see §11.22.
2749
+ - **Paging past 500 entries and whole-window totals (issue #242)** — all four
2750
+ ops (`getPosted`, `getUnposted`, `postedWithin`, `dateWorkedWindow`) take
2751
+ three more options:
2752
+ - `cursor?: string` — every rows result now carries `nextCursor: string | null`
2753
+ (the vendor's next-page cursor for the entry read). Pass it back as
2754
+ `opts.cursor` with the **same** window and filters to read the next page;
2755
+ each call stays one entry page (the vendor's continuation page, up to 500
2756
+ entries; `limit` sizes the first page only) plus at most 2 joins. Loop
2757
+ until `nextCursor` is `null` to enumerate a busy window. An off-origin
2758
+ cursor, or a same-origin cursor whose path is not the TimeEntries
2759
+ continuation (`/V1.0/TimeEntries/query/next`), or a cursor longer than
2760
+ 2,048 characters, is refused (`RESPONSE_REJECTED`) before any request. Every rows result also carries
2761
+ `unconfirmed: number`: the entries on this page whose billing-item join
2762
+ page was truncated. They are withheld from `items` (getPosted /
2763
+ getUnposted) and from both counts (postedWithin / dateWorkedWindow, which
2764
+ also withhold them from `items` when `posted` is set; with `posted`
2765
+ omitted they stay in `items`, unclassified), so a cursor loop does not
2766
+ see them as posted or unposted; `unconfirmed` is 0 normally, and when it is
2767
+ above 0 `hasMore` / `truncated` is also true. Sum it across a loop to know
2768
+ how many entries went unclassified (or use `countOnly`, which refuses
2769
+ instead).
2770
+ - `countOnly?: true` — returns only the whole window's totals:
2771
+ `{ window, total, postedCount, unpostedCount, vendorCalls, estimate, budget }`
2772
+ (typed overloads select `TimeEntriesPostedCountResult`). One
2773
+ `TimeEntries/query/count` call sizes the window; then every entry page is
2774
+ read ids-only (`IncludeFields: ['id']`, following the vendor cursor) and
2775
+ joined at 250 ids per join, and the window is counted again after the walk.
2776
+ `total` = `postedCount + unpostedCount` (de-duplicated by id). The call
2777
+ fails with `INVALID_RESPONSE` (`details: { reason: 'count_mismatch',
2778
+ streamed, counted, recounted }`) when the distinct-id total equals NEITHER
2779
+ the count before the walk NOR the count after it (a page repeated one entry
2780
+ and dropped another); a total equal to either count is accepted, so a
2781
+ window that grows or shrinks by the time the walk ends still succeeds, and
2782
+ one that changes mid-walk can fail or pass depending on timing: retry.
2783
+ `limit` and `posted` do not apply; `cursor` is rejected with `countOnly`
2784
+ (CONFIG_ERROR).
2785
+ - `maxVendorCalls?: number` — the `countOnly` budget (integer 1-200,
2786
+ default 30). The estimate is `1 + max(1, ceil(n / 500)) + ceil(n / 250) + 1`
2787
+ (the last 1 is the re-count) from the count `n` (the live week of 1,321 entries costs 11). The budget
2788
+ and `vendorCalls` count logical requests; transport retries of a 429 /
2789
+ 5xx (up to `maxRetries` each) are not counted. Over budget
2790
+ the call throws `BUDGET_EXCEEDED` (category `policy`, `details: { estimate,
2791
+ budget, total, issued }`, a `suggestedAction` naming a narrower window or
2792
+ `maxVendorCalls >= estimate`) after the count call and before any page
2793
+ read. The same code stops a walk that needs more calls than estimated
2794
+ (more entries than counted, or an extra page such as an empty
2795
+ continuation page) before the call that would exceed the budget, the
2796
+ closing re-count included; its `suggestedAction` names a narrower window
2797
+ or a `maxVendorCalls` above the budget, never `>= estimate`. A
2798
+ truncated join throws `RESOLUTION_TRUNCATED`, and a 429 propagates as
2799
+ `RATE_LIMITED`: the total is complete or not returned.
2493
2800
  - **Drifted-create marker recovery (issue #13)** — where the plan pins a
2494
2801
  `responseDrift` row (the pinned resource: `ticket_checklist_libraries`,
2495
2802
  the W-A7 drift: the vendor answers 200 with a body that is not
@@ -2587,10 +2894,17 @@ there is no second entity-type table.
2587
2894
  parallel field system, and the served row states it rather than leaving it
2588
2895
  inferred. With the `udf` include, the row's `udf` list holds the UDF
2589
2896
  DEFINITIONS the vendor's `entityInformation/userDefinedFields` channel
2590
- returns — `name`, `label`, `type`, `isRequired`, `isReadOnly`, `isPickList`,
2591
- `isReference`, `referenceEntityType`, `picklistValues` (the values ride the
2592
- `labels: true` opt, zero extra wire), `defaultValue`. A UDF definition never
2593
- appears in the plan artifact (it is tenant-specific). The row also carries
2897
+ returns, projected to `name`, `label` (when non-empty), `type`, `isRequired`,
2898
+ `isReadOnly`, `filterable: 'udf-marker'`, `isPickList`, `maxLength`,
2899
+ `referenceEntityType`, `targetResource`, `targetEnumerable`, and
2900
+ `picklistValues` (the values ride the `labels: true` opt, zero extra wire;
2901
+ present only for a picklist that exposes values). The vendor's `isReference`
2902
+ and `defaultValue` are not projected. A UDF definition never
2903
+ appears in the plan artifact (it is tenant-specific). The row's `udfCapable`
2904
+ is the plan resource flag (16 resources), which every capability record also
2905
+ carries as `CapabilityRecord.udfCapable` (issue #254); every vendor entity has
2906
+ a `getUdfInfo` catalog endpoint, so that op's presence does not make a
2907
+ resource UDF-capable. The row also carries
2594
2908
  `udfFilter: 'udf-marker'` and `udfWriteShape: 'merge'` exactly when it is
2595
2909
  `udfCapable`: a filter on a UDF NAME is accepted by the vendor only with the
2596
2910
  `udf: true` marker, so a UDF field row serves `filterable: 'udf-marker'` and
@@ -2927,7 +3241,11 @@ what makes it usable as a recovery signal from inside the client.
2927
3241
  a different host) — the re-pointed host's probe budget / close count is
2928
3242
  never touched by a stale-host probe, and the stale host's admitted
2929
3243
  budget dangles for the rest of the open period (bounded, conservative,
2930
- nothing crosses hosts). The re-point is
3244
+ nothing crosses hosts). The probe is equally pinned to the open WINDOW
3245
+ it was admitted in (issue #271): a probe that settles after that window
3246
+ closed and re-opened is voided the same way — the newer window's probe
3247
+ budget / close count is never touched by a stale-window probe. The
3248
+ re-point is
2931
3249
  recorded on the client: the live base URL is observable via
2932
3250
  `http.baseUrl` (the configured `config.baseUrl` stays the
2933
3251
  construction-time value — the re-point is process-lifetime transport
@@ -128,25 +128,25 @@ DRY-RUN-ONLY verdict (the only no-credentials path, exit 0).
128
128
 
129
129
  npm run live-coverage -- --only tickets # one resource's ops
130
130
  npm run live-coverage -- --only configuration_item_ssl # the cisan node-parity op
131
- npm run live-coverage # FULL sweep: 1182 rows, --max-calls 1600
131
+ npm run live-coverage # FULL sweep: 1281 rows, --max-calls 1600
132
132
 
133
133
  Full-sweep budget (worst case, every table non-empty — the machine-readable
134
- number is the report's `budget` block): PLAN-HONEST 930 bounded op calls
134
+ number is the report's `budget` block): PLAN-HONEST 1016 bounded op calls
135
135
  (every read/helper row = at most ONE bounded call — reads use
136
136
  `queryBounded` limit 2, a TOTAL cap; the served-artifact rows —
137
137
  operations.blindSurfaces (issue #11), operations.createGuidance (issue
138
138
  #12) — plan the op call at ZERO wire, but still count as one op call in
139
- this number: the budget counts ROW calls, not raw wire, so the 930 is the
140
- conservative row-based figure, 907 if the zero-wire rows are subtracted)
141
- + <=77 best-effort update diff GETs
142
- + <=121 cached limit-1 id reads (one per resource; the parent chain shares
143
- the cache) = <= 1128. WIRE-HONEST: the dry-run verification reads ride on
139
+ this number: the budget counts ROW calls, not raw wire, so the 1016 is the
140
+ conservative row-based figure)
141
+ + <=82 best-effort update diff GETs
142
+ + <=129 cached limit-1 id reads (one per resource; the parent chain shares
143
+ the cache) = <= 1227. WIRE-HONEST: the dry-run verification reads ride on
144
144
  top — 54 delete current-record GETs + 63 scoped-create parent-verify GETs
145
- + 20 getContext part GETs + <=15 special-op reads (incl. the issue #9
145
+ + 20 getContext part GETs + <=18 special-op reads (incl. the issue #9
146
146
  `operations.recovery` dry-run find — ONE bounded query, zero mutating
147
- wire) = <=152 — so the worst case is <= 1280 raw wire requests. The 1600
148
- --max-calls cap bounds the row-based plan (actualCalls <= 1128); the wire
149
- count cannot exceed plan + the stated verification set (and 1280 is far
147
+ wire) = <=155 — so the worst case is <= 1382 raw wire requests. The 1600
148
+ --max-calls cap bounds the row-based plan (actualCalls <= 1227); the wire
149
+ count cannot exceed plan + the stated verification set (and 1382 is far
150
150
  under the API's 10k req/hour). ZERO mutating requests: every mutation travels
151
151
  `dryRun: true` (the sweep flag + the SDK governor's dry-run-first default,
152
152
  double-locked; pinned per-resource in the generated test suite), and EVERY
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-autotask",
3
- "version": "0.34.0",
3
+ "version": "0.37.1",
4
4
  "description": "Fully-typed TypeScript SDK for the Autotask (Datto) PSA REST API. Zero runtime dependencies. Built for building MCP servers and integrations.",
5
5
  "type": "module",
6
6
  "sideEffects": false,