node-autotask 0.34.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 +50 -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-RDFL5QYE.js → chunk-2PJCQGXZ.js} +12 -12
  14. package/dist/{chunk-DKJP73FX.cjs → chunk-4GHBN2AW.cjs} +4657 -9597
  15. package/dist/{chunk-6ADFCU3I.js → chunk-75WJS26D.js} +4456 -9396
  16. package/dist/{chunk-P6YN2POI.cjs → chunk-7WR5WUQT.cjs} +143 -143
  17. package/dist/{chunk-EZKM4HQ6.js → chunk-CH2WXTYD.js} +8426 -3099
  18. package/dist/{chunk-GDQ4QM7Q.cjs → chunk-FYF46OT3.cjs} +8403 -3076
  19. package/dist/{chunk-FOOHJBPP.cjs → chunk-HBYKTXN3.cjs} +3096 -1343
  20. package/dist/{chunk-4MUBUSTL.js → chunk-KQ6HMBB4.js} +3096 -1343
  21. package/dist/{index-ByMU8IC2.d.ts → index-DAFg_68I.d.ts} +88 -11
  22. package/dist/{index-Dx4-zpDo.d.cts → index-g8J6Qiu1.d.cts} +88 -11
  23. package/dist/index.cjs +23 -23
  24. package/dist/index.d.cts +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +15 -15
  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 +321 -11
  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
@@ -1106,13 +1107,13 @@ be cross-mode-refused.
1106
1107
  and the `confirm === <op key>` gate (all 54 deletes; the 3 approval-gated
1107
1108
  writes — inside `autotask_write` too) apply exactly as in `invoke`.
1108
1109
  Pinned by the full-surface suite (`test/operations/invoke/modes.test.ts`:
1109
- all 1275 ops × 3 modes, zero wire).
1110
+ all 1281 ops × 3 modes, zero wire).
1110
1111
 
1111
1112
  **Harness approval policy (recommended).** Require approval on every tool
1112
1113
  whose MCP annotation is `readOnlyHint !== true` — today exactly **267
1113
1114
  tools**: the 265 dedicated write/delete tools (209 `effect=write` + 56
1114
1115
  `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` +
1116
+ `autotask_write` + `autotask_delete`. The remaining **635 tools** (the 622 dedicated read tools + the 10 core read tools + `autotask_read` +
1116
1117
  `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
1117
1118
  and are safe ungated: every op they can route is `effect=read` (zero
1118
1119
  mutating requests — pinned by the test inventory, §6 of the split design).
@@ -1563,10 +1564,201 @@ The secondary read re-applies the open filter client-side, and `limit` is not
1563
1564
  validated (it does not apply). With `scope` `primary`/`secondary` only that side is
1564
1565
  read and `both` is 0.
1565
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
+ ```
1757
+
1566
1758
  ## 8. Resource surface (135 resources)
1567
1759
 
1568
1760
  <!-- 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).
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).
1570
1762
 
1571
1763
  | resource key | apiEntity | kind | tier | parent | standard ops present | special ops | mcpTool |
1572
1764
  |--------------|-----------|------|------|--------|----------------------|-------------|---------|
@@ -2141,6 +2333,64 @@ own. A match that `get(id)` would return can be invisible to the lookup, so a
2141
2333
  missed dedup can issue a duplicate create; treat dedup there as best-effort
2142
2334
  until the zone is re-verified.
2143
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
+
2144
2394
  ### 11.9 Delete-boundary awareness + the one-call recovery (issue #9)
2145
2395
 
2146
2396
  The §11.1 wall (7 live-verified 405 endpoints) is now a **spec-derived
@@ -2450,12 +2700,14 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2450
2700
  condition is ever sent. The join is one `BillingItems` query per 250 ids;
2451
2701
  the entry read is capped at 500, so a call costs at most 1 read + 2 joins
2452
2702
  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
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
2454
2705
  error. The API user therefore needs BillingItems read permission; a permission fault propagates as a typed error.
2455
2706
  - **`time_entries.getPosted(opts?)` / `getUnposted(opts?)`** — `opts` is
2456
- `{ 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`:
2457
2709
  inclusive `dateWorked` days, `YYYY-MM-DD`, never tz-shifted). They return
2458
- `{ items, hasMore, window, approvedCount, unapprovedCount }` (a breaking
2710
+ `{ items, hasMore, window, nextCursor, unconfirmed, approvedCount, unapprovedCount }` (a breaking
2459
2711
  change from the bare `TimeEntry[]`, 0.32.0): `getPosted` items are entries
2460
2712
  a billing item references; `getUnposted` items are entries none references.
2461
2713
  `approvedCount` / `unapprovedCount` are informational (items with / without
@@ -2468,7 +2720,7 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2468
2720
  guard forbids Date positionals), one bounded read (`limit` 1-500, default
2469
2721
  500 — the hard cap, issue #128: a larger window needs `time_entries.query`
2470
2722
  with the same `dateWorked` filter, its 2500-row walk cap) returning
2471
- `{ items, postedCount, unpostedCount, window, truncated }`; the counts come
2723
+ `{ items, postedCount, unpostedCount, window, nextCursor, unconfirmed, truncated }`; the counts come
2472
2724
  from the billing-item join (any billing item referencing the entry), `opts.posted` narrows client-side after the join
2473
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` /
2474
2726
  `resourceID` narrow the read. Live evidence for the visibility class
@@ -2490,6 +2742,57 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2490
2742
  but, since 0.24.0, it never shifts the bounds: `dateWorked` is a date-only
2491
2743
  value at 00:00 UTC, so `from`/`to` are the days (see §7.11). The same `tz`
2492
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.
2493
2796
  - **Drifted-create marker recovery (issue #13)** — where the plan pins a
2494
2797
  `responseDrift` row (the pinned resource: `ticket_checklist_libraries`,
2495
2798
  the W-A7 drift: the vendor answers 200 with a body that is not
@@ -2587,10 +2890,17 @@ there is no second entity-type table.
2587
2890
  parallel field system, and the served row states it rather than leaving it
2588
2891
  inferred. With the `udf` include, the row's `udf` list holds the UDF
2589
2892
  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
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
2594
2904
  `udfFilter: 'udf-marker'` and `udfWriteShape: 'merge'` exactly when it is
2595
2905
  `udfCapable`: a filter on a UDF NAME is accepted by the vendor only with the
2596
2906
  `udf: true` marker, so a UDF field row serves `filterable: 'udf-marker'` and
@@ -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.0",
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,