node-autotask 0.8.3 → 0.10.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 (84) hide show
  1. package/ARCHITECTURE.md +13 -10
  2. package/MCP_TOOL_CATALOG.json +733 -251
  3. package/MCP_TOOL_MANIFEST.md +162 -133
  4. package/MCP_TOOL_OVERRIDES.json +2 -2
  5. package/N8N_MCP_MANIFEST.json +88 -25
  6. package/N8N_MCP_MANIFEST.md +26 -24
  7. package/README.md +12 -4
  8. package/capabilities.json +12503 -9316
  9. package/dist/capabilities.cjs +2 -3
  10. package/dist/capabilities.js +1 -2
  11. package/dist/{chunk-4WJCXIY2.js → chunk-3VB6N44U.js} +1141 -2977
  12. package/dist/{chunk-32AKZMY4.js → chunk-6KFP5HV3.js} +12502 -9316
  13. package/dist/chunk-DMMMWO6A.js +0 -0
  14. package/dist/chunk-FLB2X6O5.cjs +1 -0
  15. package/dist/{chunk-GYI4ZQID.js → chunk-HWC2WPX2.js} +4781 -89
  16. package/dist/chunk-IDPB4N2Q.cjs +1 -0
  17. package/dist/{chunk-F6YSQRZV.js → chunk-NVJ2PTN3.js} +7 -7
  18. package/dist/{chunk-5UGSQLLO.cjs → chunk-QUM74WLQ.cjs} +136 -136
  19. package/dist/{chunk-LAKEIYET.cjs → chunk-S7VMY5KN.cjs} +1468 -3304
  20. package/dist/chunk-UPQIUWOS.js +0 -0
  21. package/dist/{chunk-KNTUX3RR.cjs → chunk-WTVD2JZN.cjs} +12502 -9316
  22. package/dist/{chunk-V3XPLTIR.cjs → chunk-WTVLHMZA.cjs} +4784 -92
  23. package/dist/{index-B6JeThrY.d.ts → index-BOyh7S65.d.cts} +870 -79
  24. package/dist/{index-B0I2bGtx.d.cts → index-DGgMD8aq.d.ts} +870 -79
  25. package/dist/{index-DOCRp_83.d.cts → index-cQ6CRexR.d.cts} +158 -90
  26. package/dist/{index-DOCRp_83.d.ts → index-cQ6CRexR.d.ts} +158 -90
  27. package/dist/index.cjs +37 -22
  28. package/dist/index.d.cts +46 -5
  29. package/dist/index.d.ts +46 -5
  30. package/dist/index.js +48 -33
  31. package/dist/operations/index.cjs +480 -359
  32. package/dist/operations/index.d.cts +195 -16
  33. package/dist/operations/index.d.ts +195 -16
  34. package/dist/operations/index.js +370 -249
  35. package/dist/pool.cjs +36 -12
  36. package/dist/pool.d.cts +2 -3
  37. package/dist/pool.d.ts +2 -3
  38. package/dist/pool.js +33 -9
  39. package/dist/resources/index.cjs +5 -6
  40. package/dist/resources/index.d.cts +2 -2
  41. package/dist/resources/index.d.ts +2 -2
  42. package/dist/resources/index.js +4 -5
  43. package/dist/types/index.cjs +1 -2
  44. package/dist/types/index.d.cts +1 -1
  45. package/dist/types/index.d.ts +1 -1
  46. package/dist/types/index.js +1 -2
  47. package/dist/untrusted.cjs +0 -1
  48. package/dist/untrusted.js +0 -1
  49. package/docs/API.md +693 -48
  50. package/docs/N8N-ADAPTER.md +4 -4
  51. package/examples/basic.ts +1 -1
  52. package/package.json +5 -3
  53. package/dist/capabilities.cjs.map +0 -1
  54. package/dist/capabilities.js.map +0 -1
  55. package/dist/chunk-32AKZMY4.js.map +0 -1
  56. package/dist/chunk-4WJCXIY2.js.map +0 -1
  57. package/dist/chunk-5UGSQLLO.cjs.map +0 -1
  58. package/dist/chunk-CXM35TEC.cjs +0 -1
  59. package/dist/chunk-CXM35TEC.cjs.map +0 -1
  60. package/dist/chunk-F6YSQRZV.js.map +0 -1
  61. package/dist/chunk-GIVD2CQ3.js +0 -1
  62. package/dist/chunk-GIVD2CQ3.js.map +0 -1
  63. package/dist/chunk-GYI4ZQID.js.map +0 -1
  64. package/dist/chunk-KNTUX3RR.cjs.map +0 -1
  65. package/dist/chunk-LAKEIYET.cjs.map +0 -1
  66. package/dist/chunk-MHBMSBTA.js +0 -1
  67. package/dist/chunk-MHBMSBTA.js.map +0 -1
  68. package/dist/chunk-NZUFSSIZ.cjs +0 -1
  69. package/dist/chunk-NZUFSSIZ.cjs.map +0 -1
  70. package/dist/chunk-V3XPLTIR.cjs.map +0 -1
  71. package/dist/client-DHmr0Oyx.d.ts +0 -203
  72. package/dist/client-Q6NoqinP.d.cts +0 -203
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/operations/index.cjs.map +0 -1
  76. package/dist/operations/index.js.map +0 -1
  77. package/dist/pool.cjs.map +0 -1
  78. package/dist/pool.js.map +0 -1
  79. package/dist/resources/index.cjs.map +0 -1
  80. package/dist/resources/index.js.map +0 -1
  81. package/dist/types/index.cjs.map +0 -1
  82. package/dist/types/index.js.map +0 -1
  83. package/dist/untrusted.cjs.map +0 -1
  84. package/dist/untrusted.js.map +0 -1
package/docs/API.md CHANGED
@@ -37,7 +37,7 @@ const autotask = new AutotaskClient({
37
37
  // maxRetries: 3,
38
38
  // maxConcurrent: 10,
39
39
  // rateLimit: { perHour: 10_000 }, // perHour: null = explicitly disabled
40
- // resolution: { maxScanRecords: 500, maxScanPages: 4 },
40
+ // resolution: { maxScanRecords: 500, maxScanPages: 4, scanOnMiss: false },
41
41
  // logger: console,
42
42
  // onAudit: (event) => { /* one event per completed request */ },
43
43
  });
@@ -117,8 +117,9 @@ server; a blank 2xx on a call that expects a result; `details = { httpStatus,
117
117
  method }`) — both classified and documented in section 2.1.
118
118
  `isDeterministicVendor500(entries)` (over the `parseVendorErrors` output) is
119
119
  the documented HEURISTIC phrase matcher for deterministic vendor
120
- field-validation 500s (`exceeds maximum length` to start — it holds only
121
- wording seen in real responses): it drives the transport's no-retry decision
120
+ field-validation 500s (`exceeds maximum length`, `Missing Required Field` and `Value does not
121
+ exist for the required field` — it holds only wording seen in real responses; the last two were
122
+ live-verified on `company_notes.create`): it drives the transport's no-retry decision
122
123
  and the `errorFromStatus` override that marks a matching 500 permanent
123
124
  (`retryable: false`, `details.deterministic: true`, a field-catalog
124
125
  `suggestedAction`) while keeping the `ServerError` class + `SERVER_ERROR`
@@ -144,7 +145,13 @@ served payload verbatim).
144
145
  `buildFilter(conditions)` wraps caller conditions (multiple top-level
145
146
  conditions are `and`-wrapped); `validateFilter` enforces the vendor caps
146
147
  (section 5 below). `buildQueryRequest` produces the `QueryModel` wire body
147
- (`{ MaxRecords, IncludeFields?, filter }`).
148
+ (`{ MaxRecords, IncludeFields?, filter }`). `normalizedConditions` is the
149
+ shared filter-shape gate of the query family (every query-family entry
150
+ point routes through it pre-request): the `filter` param accepts a
151
+ conditions array or `{ filter: [...] }`, and `undefined`/`null` are the
152
+ unfiltered call; any other defined shape is REFUSED pre-request with a
153
+ typed `AutotaskConfigError` (zero wire) — never silently dropped.
154
+
148
155
 
149
156
  ### 1.8 `logger.ts` + `utils/`
150
157
 
@@ -196,6 +203,8 @@ status → what the vendor does that triggers it.
196
203
  |-------|------|----------|--------|----------------|
197
204
  | `AutotaskError` | `AUTOTASK_ERROR` / `HTTP_<status>` | server | unmapped | any non-mapped HTTP status |
198
205
  | `AutotaskConfigError` | `CONFIG_ERROR` | validation | — (client-side) | fail-closed constructor validation; unknown `invoke` operation; cross-mode `invoke` refusal (the mode tools); bad zone/baseUrl; missing client accessor |
206
+ | `AutotaskConfigError` | `LATEST_THRESHOLD_UNRESOLVED` | policy | — (no call beyond the 64 count probes) | `queryBounded latest`: the id-threshold count probes exhausted the 64-call budget without resolving a window — `suggestedAction` names the date-window narrowing (issue #59) |
207
+ | `AutotaskConfigError` | `LATEST_WINDOW_INCOMPLETE` | policy | — (the walk refuses to continue) | `queryBounded latest`: the walk truncated (the limit with a live next cursor, or the 1000-page guard) — a truncated enumeration's top-N-by-id is unprovable, so the op refuses instead of a possibly-wrong answer — `suggestedAction` names the date-window narrowing (issue #59) |
199
208
  | `AutotaskNetworkError` | `NETWORK_ERROR` | network | — | `fetch` failure (DNS, connection reset) — retryable |
200
209
  | `AutotaskTimeoutError` | `TIMEOUT` | timeout | — | whole-call budget (`timeoutMs`) exceeded — retryable |
201
210
  | `BadRequestError` | `BAD_REQUEST` | validation | 400 | malformed request/filter; the vendor body's field errors are parsed into `vendorError` |
@@ -210,7 +219,7 @@ status → what the vendor does that triggers it.
210
219
  | `UnexpectedContentTypeError` | `UNEXPECTED_CONTENT_TYPE` | server | 2xx (non-JSON body + a non-JSON-ish `Content-Type`) | the edge serves a non-JSON page (maintenance) — the vendor never processed the request; `details = { httpStatus, contentType, bodySnippet (256 B) }` — retryable (advisory; idempotent methods retry per policy, a write makes ONE call — section 2.1) |
211
220
  | `EmptyResponseError` | `EMPTY_RESPONSE` | server | 2xx (empty body, `expectBody`) | a blank 2xx on a call that expects a result (non-DELETE by default — section 2.1); `details = { httpStatus, method }` — retryable for non-DELETE (advisory: the loop never retries 2xx) |
212
221
  | `CircuitBreakerError` | `CIRCUIT_BREAKER_OPEN` | policy | — (call not sent) | the host circuit breaker (issue #26, opt-in) is open — refused LOCALLY before the request (zero upstream calls, no hourly stamp) — `notSent: true`, `retryAfter` = remaining open seconds — **retryable: true** (the deliberate divergence from #6's not-retryable cooldown refusal: the open period is bounded and advertised; section 3.2.2) |
213
- | `ResolutionError` | `RESOLUTION_AMBIGUOUS` / `RESOLUTION_TRUNCATED` / `NOT_FOUND` | resolution | — | `resolve`: multiple exact matches (candidates in `resourceIds`); a cap-tripped bounded client scan (never a silent `null`); a numeric-id miss |
222
+ | `ResolutionError` | `RESOLUTION_AMBIGUOUS` / `RESOLUTION_TRUNCATED` / `NOT_FOUND` | resolution | — | `resolve`: multiple exact matches (candidates in `resourceIds`); a cap-tripped bounded client scan (`resolution.scanOnMiss` only; never a silent `null`); a numeric-id miss |
214
223
  | `PaginationLimitError` | `PAGINATION_LIMIT` | policy | — | a page walk that would exceed the 1000-page guard (`suggestedAction` names the tighter filter) |
215
224
  | `QueryTooLongError` | `QUERY_TOO_LONG` | policy | — | caller-requested `via: 'get'` whose serialized URL would exceed the 2048-char GET cap — the SDK never silently degrades to POST |
216
225
  | `StaleObjectError` | `STALE_OBJECT` | conflict | — | the caller-supplied `lastModifiedDate` no longer matches the vendor's current value (no `If-Match`/ETag exists) |
@@ -319,8 +328,8 @@ upstream call (it breaks out before the retry decision — the parsed body is
319
328
  already in hand at the retry site), and the mapped error keeps the
320
329
  `ServerError` class + `SERVER_ERROR` code + status 500 but carries
321
330
  `retryable: false`, `details.deterministic: true`, and a deterministic
322
- `suggestedAction` naming the resource's `getFieldInfo` (the field catalog
323
- with `maxLength`). Any other 5xx body is byte-identical to before (retried
331
+ `suggestedAction` pointing at `operations.fieldCatalog` (the field catalog
332
+ with types, required and `maxLength`). Any other 5xx body is byte-identical to before (retried
324
333
  per policy, `retryable: true`) — the phrase list is a heuristic over wording
325
334
  seen on real responses; widen it only with new live evidence.
326
335
 
@@ -364,10 +373,12 @@ Host form: `https://<name>.autotask.net/atservicesrest` (lowercase
364
373
  | `circuitBreaker` | off | — | opt-in per-host circuit breaker (issue #26): `failureThreshold` (5) counted failures inside `failureWindowMs` (10 s) open the circuit for `openTimeoutMs` (30 s); `successesToClose` (2) probe successes close it; `halfOpenProbes` (default `= successesToClose`; validated `>= successesToClose`) bounds the half-open probes. Key = the host of the base URL (host-only — on a shared host, one tenant's outage opens the circuit for that host's other tenants; section 3.2.2). `thresholdInformation.get()` bypasses the refusal (the outcome is still recorded); `store` / `isFailure` inject the store / the failure predicate |
365
374
  | `resolution.maxScanRecords` | 500 | — | bounded client-scan cap (section 7, `resolve`) |
366
375
  | `resolution.maxScanPages` | 4 | — | bounded client-scan cap |
376
+ | `resolution.scanOnMiss` | `false` | — | re-check a bare-value `resolve` miss with the bounded client scan (exact match on the search fields). Off: the server filter's zero items is a complete non-match (`null`, one call) |
367
377
  | `logger` | `NoopLogger` | — | request log sink |
368
378
  | `redactPersonalData` | `true` | — | response redaction for PII + confidential fields (section 10); `false` = raw personal data, secrets still redacted |
369
- | `cache` | off | — | `{ ttlMs, perResourceTtlMs? }` opt-in read-through cache for get-by-id and `resolve` (snapshot at fetch time; a real write through the same client clears the cache and mutation-path reads bypass it). `perResourceTtlMs` (issue #32): a resource plan key -> ms map — a named resource expires on its own TTL, every other resource (and an absent map) falls back to `ttlMs` (absent map = bit-for-bit the pre-#32 behaviour); the plan-derived default table `DEFAULT_PER_RESOURCE_TTL_MS` (`src/cache-ttl-tables.ts` — reference class 1h vs default class 5min) is a reference for building the map, never applied implicitly (section 11.15) |
370
- | `fieldCache` | off | `maxEntities` 256 | `{ ttlMs, maxEntities? }` opt-in live field-metadata channel for the entityInformation trio (`getEntityInfo` / `getFieldInfo` / `getUdfInfo`) — see section 11.14. With it unset the trio is the uncached wire path, bit-for-bit. Live reads are snapshots at fetch time; a vendor drift (a deterministic validation 500, or an INVALID_RESPONSE on a metadata read) invalidates the named entity's entries; a record write never invalidates (it says nothing about the field catalog). Empty results are never cached; concurrent callers share one in-flight request |
379
+ | `cache` | on | — | `{ ttlMs?, perResourceTtlMs? }` read-through cache for get-by-id and `resolve` (issue #80: default-on — absent = the default instance, 5 min + the default per-resource table; `false` = off, the uncached path bit-for-bit). Snapshot at fetch time; a real write through the same client clears the cache and mutation-path reads bypass it. `perResourceTtlMs` (issue #32): a resource plan key -> ms map — a named resource expires on its own TTL, every other resource falls back to `ttlMs`; operator entries OVERRIDE the plan-derived default table `DEFAULT_PER_RESOURCE_TTL_MS` (`src/cache-ttl-tables.ts` — reference class 1h vs default class 5min), which IS the default map (applied by default since #80; section 11.15) |
380
+ | `fieldCache` | on | `maxEntities` 256 | `{ ttlMs?, maxEntities? }` live field-metadata channel for the entityInformation trio (`getEntityInfo` / `getFieldInfo` / `getUdfInfo`) (issue #80: default-on — absent = the default instance, 5 min; `false` = the uncached wire path, bit-for-bit) — see section 11.14. With it `false` the trio is the uncached wire path, bit-for-bit. Live reads are snapshots at fetch time; a vendor drift (a deterministic validation 500, or an INVALID_RESPONSE on a metadata read) invalidates the named entity's entries; a record write never invalidates (it says nothing about the field catalog). Empty results are never cached; concurrent callers share one in-flight request |
381
+ | `resourceNameCache` | on | `maxEntries` 1024 | `{ ttlMs?, maxEntries? }` resource-name channel for `operations.expandResourceNames` (issue #65; issue #80: default-on — absent = the default instance, 1 h; `false` = the uncached per-id path) — see section 11.21. With it `false` the op is the uncached per-id path (per-call de-duplication still applies). Live names are snapshots at fetch time; a `resources` write through the same client clears the channel, writes elsewhere show up once `ttlMs` expires. Failures are never cached; a vendor true not-found IS cached as a blind marker (a repeat miss costs no wire) |
371
382
  | `update` | off | — | `{ putFallback?: boolean }` opt-in PUT item-path fallback for a 404'd non-collectionUpdate update route (issue #34) — see section 11.16. A TYPED 404 retries ONCE as `PUT /{Entity}/{id}` with the caller's fields merged onto a fresh unredacted read (never a null-clearing partial); collectionUpdate routes, `raw: true` updates and every non-404 fault are never retried — a declined gate throws the self-explaining error naming the route, the status and the opt-in |
372
383
  | `reResolveZoneOn401` | off | — | opt-in zone re-resolution on 401 (issue #43) — see section 11.19. A 401 on a relative path against a CATALOG base makes ONE unauthenticated vendor zone-discovery call; if the tenant's zone base has moved, the client re-points its live base URL (process-lifetime, observable via `http.baseUrl`) and retries the SAME request ONCE. An explicit `baseUrl` and an absolute path are never re-pointed; a resolved base identical to the live one, or a discovery fault, propagates today's typed 401 untouched (zero retry); a retry that 401s again carries `details.zoneRecovery = { from, to, retried: true }` |
373
384
  | `hostPolicy` | off | — | opt-in config-time HOST BOUNDS (skill 3.5.0 / L2) — see section 11.20. `{ blockPrivateHosts?: boolean; allowedOrigins?: readonly string[] }`: the RESOLVED base URL (explicit `baseUrl` or zone-derived) must pass every ACTIVE bound at construction time, pre-wire; a violation throws the typed `HostPolicyDeniedError` (`HOST_POLICY_DENIED`, category `policy`, `retryable: false`) naming the offending origin. `blockPrivateHosts: true` refuses a LITERAL private / loopback / link-local host (IPv4 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 0/8; IPv6 `::1`, `fc00::/7`, `fe80::/10`, IPv4-mapped `::ffff:a.b.c.d`); `allowedOrigins` is an EXACT normalised-origin allow-list. Checks run on the canonical `new URL(origin).host` — HOSTNAMES are never blocked (no DNS lookups); the private bound is checked FIRST and WINS; a present block with no active bound is refused fail-closed; with `reResolveZoneOn401: true` the same bounds gate the discovery-provided re-point target (a refused host is a discovery fault) |
@@ -526,11 +537,16 @@ List reads are `GET|POST /V1.0/<E>/query` returning the pinned envelope:
526
537
 
527
538
  ```json
528
539
  { "items": [ "<Model>", ... ],
529
- "pageDetails": { "count": 10, "requestCount": 1234,
530
- "prevPageUrl": null, "nextPageUrl": null } }
540
+ "pageDetails": { "count": 10, "requestCount": 10,
541
+ "prevPageUrl": null, "nextPageUrl": "<cursor>" } }
531
542
  ```
532
543
 
533
- `requestCount` = total matching records; `count` = records on this page.
544
+ `count` = records on this page. `requestCount` is NOT a match count: the
545
+ vendor echoes the `MaxRecords` the request asked for (500 when omitted) —
546
+ live-verified: `MaxRecords: 5` over 13,220 matches → `requestCount: 5`; a
547
+ zero-match page with `MaxRecords: 10` → `requestCount: 10`. The vendor
548
+ publishes no total on a page; the only authoritative total is `count()`
549
+ (`POST /V1.0/<E>/query/count`). Whether more rows exist is `nextPageUrl`.
534
550
  Single-record reads (`GET /V1.0/<E>/{id}`) and count/entityInfo reads never
535
551
  carry paging parameters.
536
552
 
@@ -539,8 +555,12 @@ carry paging parameters.
539
555
  later pages POST the preserved filter to the ABSOLUTE `nextPageUrl`, rebased
540
556
  to the client base URL (same-origin cursors verbatim; cross-origin re-based —
541
557
  the node's custom-zone behaviour).
542
- - `Page<T>`: `{ items, page (1-based), pageSize, requestCount, hasMore
543
- (nextPageUrl !== null), totalPages (ceil(requestCount / pageSize)) }`.
558
+ - `Page<T>`: `{ items, page (1-based), pageSize, requestCount (the vendor
559
+ value verbatim — the MaxRecords echo, not a total), hasMore (nextPageUrl
560
+ !== null), totalPages }`. `totalPages` is the pages KNOWN so far —
561
+ `page + 1` while `hasMore`, else `page` (`page - 1` for an empty page after a full one; 0 for an empty result): a lower
562
+ bound mid-walk, exact on the last page. For a real page count use
563
+ `totalPagesFor(await r.count(filter), pageSize)`.
544
564
  - **Auto-stop** on a short/empty page (or a null cursor, which is `hasMore:
545
565
  false`).
546
566
  - **1000-page guard:** a walk that would fetch a 1001st page throws
@@ -563,6 +583,72 @@ carry paging parameters.
563
583
  when the limit or the guard truncated the walk and more records exist past
564
584
  the stop point; `pageDetails.count` is never surfaced (it is the current
565
585
  page's record count — `<resource>.count` is the total).
586
+ - **`latest: N` (issue #59) — the N highest-`id` rows without caller paging.**
587
+ The vendor has NO sort parameter (the vendored spec's `QueryModel` is
588
+ exactly `maxRecords` / `includeFields` / `filter`), so `latest` is a
589
+ COUNT-ANCHORED mechanism — the `id` primary key is the only order the SDK
590
+ can prove, and `id` rides every row (the `includeFields` id-append,
591
+ section 5 — `latest` DEPENDS on it). ONE rowless count probe decides the
592
+ mode: `C <= 2500` → the walk of the matching set is COMPLETE (1 count +
593
+ ≤5 walk pages, wire `1 + ceil(C/500) <= 6`, independent of table size) —
594
+ all `C` rows are collected, sorted id-descending, and the top `N`
595
+ returned (a set of `C < N` returns itself); `C > 2500` → a full walk
596
+ would be truncated and could not prove "highest id", so the SDK finds the
597
+ smallest id threshold `T` with `count(F ∧ id gt T) <= N` by exponential
598
+ bracketing + binary search (ONE rowless count call per probe; the total
599
+ budget is 64 probes — exhaustion throws the typed
600
+ `LATEST_THRESHOLD_UNRESOLVED`) and walks the window `F ∧ id gte T`
601
+ (≤6 pages) — the window holds exactly `N+1` rows (ids are unique primary
602
+ keys), so its top `N` ARE the `N` highest overall. `hasMore` is `false`
603
+ on a complete `latest` answer (complete FOR THE REQUEST — the table may
604
+ hold more rows; `<resource>.count` is the total). On a plan-pinned BLIND
605
+ surface the count cannot be trusted (a blind 0 proves nothing): the walk
606
+ runs up to `limit` (default 500) instead — a completing walk returns the
607
+ sorted top-N WITH the blind-surface sentinel stamp (the "highest id"
608
+ claim is scoped to the caller-visible enumeration), and a TRUNCATED walk
609
+ (the limit reached with a live next cursor, or the 1000-page guard)
610
+ throws the typed `LATEST_WINDOW_INCOMPLETE` — a truncated enumeration's
611
+ top-N-by-id is unprovable, so the op refuses instead of returning a
612
+ possibly-wrong answer. Both refusals carry a `suggestedAction` naming the
613
+ escape hatch: narrow with a **date window in `filter`** (or a smaller
614
+ `latest`) so the matching set fits the `limit` / the 2500-row full-set
615
+ mode — a date window is never REQUIRED, it is the natural narrowing that
616
+ makes everything cheap. `limit` and `latest` may coexist only when
617
+ `limit >= latest` (above that the returned count is governed by
618
+ `latest`; `limit` is the blind-branch walk window). `latest` is
619
+ query-verb-ONLY (`queryAll` / `count` bags are unchanged).
620
+ - **`noteChars: N` (issue #61) — the opt-in per-field free-text cap.**
621
+ `N` (integer >= 1, pre-request validated, zero wire) caps EVERY
622
+ string value in the resource's untrusted-class free-text fields
623
+ (notes/descriptions/UDF prose — the generated
624
+ `UNTRUSTED_FIELDS_BY_RESOURCE` set, the SAME plan column the
625
+ registry's `metadata.untrustedFields` channel stamps and the server
626
+ wraps). Each capped value becomes the first `N` chars + the explicit
627
+ marker `… [+N chars truncated]` (N = chars removed; deterministic —
628
+ no timestamps). The truncation runs at result-build — BEFORE the
629
+ server-side untrusted wrapper (the src/untrusted.ts contract: the
630
+ caller truncates first, then wraps; the marker rides INSIDE the value,
631
+ so the wrapper wraps the truncated value + marker) — and is
632
+ response-side only (zero wire). ABSENT = the raw, untruncated result
633
+ bit-for-bit; the additive `noteCharsTruncated` result field counts the
634
+ truncated fields and is present ONLY when > 0. The cap is
635
+ query-verb-ONLY (`queryAll` / `count` bags are unchanged; the raw
636
+ `.query()` walker is uncapped — section 8's `queryBounded` is the MCP
637
+ query surface the issue targets).
638
+ - **`fieldsPreset: name` (issue #61) — the closed includeFields
639
+ presets.** The named preset is resolved PRE-WALK to its `includeFields`
640
+ list (the vendor's own column selection rides the existing `IncludeFields`
641
+ wire path; `id` is always appended by the id-append — zero extra wire).
642
+ v1 ships the one preset the issue names: `billing_join` on
643
+ `billing_items` (the 14 join/measure columns — section 11.23). The
644
+ closed-bag refusals are pre-request (zero wire): unknown preset name
645
+ (the valid names are named), preset/resource mismatch (the preset and
646
+ its resource are named), and `fieldsPreset` + an explicit
647
+ `includeFields` in the same bag (both are named — no silent
648
+ precedence). The preset FIELD EXISTENCE is verified against the LIVE
649
+ field catalogue (the live-smoke check — the review correction's
650
+ mandate; the vendored spec is a non-authoritative guard only), not
651
+ against `MODEL_FIELD_SETS`.
566
652
  - Raw SDK reads are unchanged: `queryAll` (deterministic plain `T[]`,
567
653
  unbounded, 1000-page guarded) and `query` (stream, `for await ...`). The
568
654
  MCP surface steers the caller to the bounded `query` op; `queryAll` is for
@@ -585,8 +671,23 @@ Vendor filter structure (swagger `Filter`, recursive):
585
671
  contains, exist, notExist, in, notIn` + grouping `and`, `or`. **There is no
586
672
  `not` grouping operator** (G1 pinned): negation is expressed with
587
673
  `noteq`/`notExist`/`notIn`.
674
+ - `gt` / `gte` / `in` on `id` are the SDK's own paging/threshold mechanism:
675
+ the `queryBounded` `latest` opt-in (section 4) probes and walks `id gt` /
676
+ `id gte` count windows, and the vendored spec's own `QueryModel` example is
677
+ exactly an `id gt` filter — the vendor-side acceptance across zone versions
678
+ is a maintainer live-smoke check (the SDK never assumes page order).
588
679
  - `buildFilter(conditions)` wraps the caller's conditions; multiple
589
680
  top-level conditions are `and`-wrapped (the node's behaviour).
681
+ - **Refusal, not silent drop (issue #76):** the query-family `filter`
682
+ param accepts a conditions array (`FilterCondition[]`) or
683
+ `{ filter: [...] }`; `undefined`/`null` are the unfiltered call
684
+ (`[]` is the vendor-accepted no-filter wire shape). Every other defined
685
+ shape (a bare condition object, a bare group object, an object missing
686
+ the `filter` field, a string, a number) is REFUSED pre-request with a
687
+ typed `AutotaskConfigError` — zero wire — at every entry point (query,
688
+ queryBounded, cursor pages, count): a non-conformant filter is never
689
+ silently dropped into an unfiltered query. The gate is the shared
690
+ `normalizedConditions` (query.ts), so no entry point can drift.
590
691
  - **Vendor caps (`validateFilter`):** <= 500 OR-conditions; <= 1 UDF filter
591
692
  per query (`udf: true`); field length <= 128; description length <= 2000.
592
693
  - **GET vs POST:** the API caps the GET `search` JSON string at **2048 URL
@@ -595,7 +696,10 @@ Vendor filter structure (swagger `Filter`, recursive):
595
696
  serialized GET URL — over 2048 it throws `QueryTooLongError` (never a
596
697
  silent degradation).
597
698
  - `includeFields` passes through vendor column selection; the SDK never
598
- invents column selection.
699
+ invents column selection — one exception: when `includeFields` is set the
700
+ SDK always appends `id` (the vendor omits `id` from a row unless it is
701
+ named in the selection, and every caller needs the id to act on the row —
702
+ issue #59).
599
703
 
600
704
  ## 6. Mutation safety
601
705
 
@@ -667,8 +771,8 @@ resource's `resolve` with the SAME identifier. Unlike single-resource
667
771
  `resolve`, **a miss does NOT throw**: the result lists per-resource
668
772
  candidates and the error code when a resource could not resolve (empty
669
773
  `candidates` = no match). Bare-value order per resource: numeric id, then
670
- the primary text field, exact match only; the bounded client-scan fallback
671
- (and its truncation cap) apply per resource. Returns
774
+ the primary text field, exact match only; with `resolution.scanOnMiss` the
775
+ bounded client-scan re-check (and its truncation cap) applies per resource. Returns
672
776
  `{ identifier, results: [{ resource, value, resolutionCost, scanned,
673
777
  scanTruncated, candidates, error? }], matches }`.
674
778
 
@@ -743,7 +847,7 @@ be cross-mode-refused.
743
847
  whose MCP annotation is `readOnlyHint !== true` — today exactly **254
744
848
  tools**: the 252 dedicated write/delete tools (198 `effect=write` + 54
745
849
  `effect=destructive`, every one of them has a dedicated tool) +
746
- `autotask_write` + `autotask_delete`. The remaining **571 tools** (the 558
850
+ `autotask_write` + `autotask_delete`. The remaining **600 tools** (the 587
747
851
  dedicated read tools + the 10 core read tools + `autotask_read` +
748
852
  `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
749
853
  and are safe ungated: every op they can route is `effect=read` (zero
@@ -817,6 +921,175 @@ const applied = await recovery(client, { resource: 'configuration_item_dns_recor
817
921
  Pinned by `test/operations/recovery.test.ts` (11 tests) + the full-surface
818
922
  governor suite (`test/operations/invoke-modes.test.ts`).
819
923
 
924
+ ### 7.6 `fetchMany(client, items, opts?)` / `fetchByIds(client, items, opts?)` — bulk fetch by ids
925
+
926
+ Two cross-resource bulk reads with per-item typed results — one entry per
927
+ input occurrence: `{ resource, id, found, value, error?: { code, httpStatus?,
928
+ message } }`; a NOT_FOUND or fault on one item never aborts the rest; the
929
+ result is `{ results, ok, failed }`. Both refuse pre-request (zero wire):
930
+ non-numeric string ids (pointing at `autotask_resolve_any` — canonical
931
+ integer strings above 2^53 pass with their exact digits), parent-scoped
932
+ children (their `get` needs `parentId`), unknown `fields` keys (the complete
933
+ problem list in one message), unsafe number ids (the canonical-string
934
+ pointer). The op never fabricates rows: values come only from a vendor 2xx
935
+ (query row or GET record); a miss is a typed outcome, never a null-padded
936
+ row.
937
+
938
+ - **`fetchMany`** — up to 20 items (mixed resources; the pinned
939
+ `FETCH_MANY_MAX_ITEMS`; over → `CONFIG_ERROR` pre-request, zero wire),
940
+ one sequential GET per item. Per-item `fields?: string[]` is a client-side
941
+ projection (the GET returns the full model; projection is post-fetch, zero
942
+ extra wire).
943
+ **Live field validation (opt-in `opts.liveFields`, issue #66)**: when
944
+ `true`, each item's `fields` validate against the tenant's **live** entity
945
+ field metadata (the existing cached metadata channel — with
946
+ `fieldCache` configured a warm cache costs zero wire; a cold run is one
947
+ `entityInformation/fields` GET per DISTINCT resource in the batch) instead
948
+ of the vendored-spec field set alone. A resource whose metadata is
949
+ unavailable (network fault, vendor 5xx) falls back to the vendored-spec
950
+ set for THAT resource only — unavailability never throws and never blocks
951
+ the batch. When available the live set is authoritative: a field it lacks
952
+ is refused even if the spec lists it, and a live field the spec lacks is
953
+ accepted — a tenant whose live TimeEntry entity exposes `postedDate` (the
954
+ live record carries it even when the vendored model omits it) may project
955
+ it once `liveFields: true`; the default spec-only path still refuses it.
956
+ The default (no `liveFields`) is today's vendored-spec-only validation,
957
+ bit-for-bit, zero metadata wire. Field discovery in both modes:
958
+ `operations.fieldCatalog`.
959
+ - **`fetchByIds`** — up to 500 items (mixed resources; the pinned
960
+ `FETCH_BY_IDS_MAX_ITEMS`; `opts.maxItems` may lower the cap, never raise
961
+ it; over → `CONFIG_ERROR` pre-request, zero wire). **Query-first**: per
962
+ resource, the unique ids are chunked at 250 (the pinned
963
+ `FETCH_BY_IDS_CHUNK_SIZE` — the 250-id `in` condition serializes well under
964
+ the 8000-char vendor cap, and 250 is a deliberate chunk size well under the
965
+ 500 vendor page max, so each chunk is a single page, no cursor walk) and fetched with one `id in`
966
+ query per chunk. Every id the chunk query does NOT return is re-tried with
967
+ a per-item `get` fallback before it is reported — the GET is the authority
968
+ on every surface, so a blind-surface query-miss is re-verified by the
969
+ authority read before a NOT_FOUND is served (the fallback is universal, not
970
+ limited to the plan-pinned blind surfaces). Wire cost: per resource
971
+ `ceil(uniqueIds/250)` query calls + one `get` per query-missed id — a
972
+ clean 500-id single-resource batch is **2 wire** (down from 500 sequential
973
+ GETs); the blind-surface worst case is 2 + up to 500 gets (fetchMany
974
+ parity, never worse). A chunk-level vendor fault faults every item of that
975
+ chunk (the chunk's `code`/`httpStatus`) and the remaining chunks still run
976
+ (input order is preserved across chunks and groups; a duplicate id gets its
977
+ own entry with the identical outcome over one wire fetch).
978
+
979
+ ### 7.7 `expandResourceNames(client, ids, opts?)` — resource name expansion (issue #65)
980
+
981
+ The opt-in cross-resource helper that resolves vendor numeric resource
982
+ ids (the `resourceID` values other tables expose WITHOUT names — the
983
+ `resourceID` of time_entries / billing_items / tickets) to display names
984
+ in one call. Wire shape: **one `GET /V1.0/Resources/{id}` per unique id,
985
+ sequentially — no POST, no `/query`, no `id in`** (AC4 by construction:
986
+ the module imports no query builder) — the resources table's count/query
987
+ can be blind to the API user while per-id reads work, so an `id in`
988
+ lookup on a blind table would silently return nothing; the per-id GET is
989
+ the authority read (section 11.2).
990
+
991
+ - **Input**: `ids: (number | string)[]` — vendor numeric resource ids
992
+ (safe integers, or the canonical integer string for ids above 2^53 —
993
+ the exact digits ride the wire URL verbatim). Non-numeric string ids
994
+ are refused pre-request pointing at `autotask_resolve_any` (the
995
+ complete problem list in one message, zero wire). Cap 500 per call
996
+ (the pinned `EXPAND_RESOURCE_NAMES_MAX_IDS`; `opts.maxIds` may lower
997
+ the cap, never raise it); over → `CONFIG_ERROR` pre-request, zero wire.
998
+ - **Per-call de-duplication** is always on: repeated ids ride one wire
999
+ fetch; each occurrence gets its own row with the identical outcome.
1000
+ - **Per-row** `{ id, name, blind, error? }`:
1001
+ - `name` = composed `firstName [middleName] lastName` (single spaces;
1002
+ the vendor `Resource` model has NO single name field —
1003
+ `suffix`/`initials` excluded; the raw parts via `operations.fetchByIds`
1004
+ with `fields`);
1005
+ - a record FOUND with no name part → `{ name: null, blind: false }`
1006
+ (a vendor data gap — distinct from blind);
1007
+ - a per-id miss (HTTP 404 or `200 item: null` — the vendor's TRUE
1008
+ not-found) → `{ name: null, blind: true }`, the id kept, NO error, NO
1009
+ throw (AC3 — the invisible-table case: at the per-id level the vendor
1010
+ does not distinguish "table invisible to this user" from "record
1011
+ absent", so both are blind);
1012
+ - a transient typed fault (5xx class) → per-row
1013
+ `error: { code, httpStatus?, message }` — never cached, one fault
1014
+ never aborts the remaining ids.
1015
+ - **Result**: `{ results (input order, duplicates get identical rows),
1016
+ resolved, blind, failed }`.
1017
+ - **Wire cost**: `N` unique ids cold = `N` GETs; warm (inside the TTL of
1018
+ the channel below) = 0.
1019
+ - **Cache**: the `resourceNameCache` channel (section 11.21) —
1020
+ each distinct id is fetched at most once per cache lifetime (the blind
1021
+ marker is cached too: a repeat miss costs no wire; a failure is never
1022
+ cached). Default-on (issue #80) — with `false` the op is the uncached per-id
1023
+ path (per-call de-duplication still applies). A `resources` write
1024
+ through the same client clears the channel; a rename made elsewhere
1025
+ shows up once `ttlMs` expires (the documented staleness contract). A
1026
+ `fresh: true` call bypasses BOTH the name cache AND the ReadCache (the
1027
+ per-id `resources.get` fetches live and refreshes the ReadCache entry as
1028
+ well) — a rename made elsewhere is served on the very next fresh call,
1029
+ never a stale snapshot (issue #77).
1030
+
1031
+ ```ts
1032
+ import { expandResourceNames } from 'node-autotask/operations';
1033
+ const { results, resolved, blind, failed } = await expandResourceNames(
1034
+ client,
1035
+ [101, 102, '103'], // vendor numeric resource ids (exact digits ride the wire verbatim)
1036
+ { fresh: false },
1037
+ );
1038
+ ```
1039
+
1040
+
1041
+ ### 7.8 `aggregate(client, operation, filter, opts?)` — generic aggregate/count (issue #62)
1042
+
1043
+ One op, two mode families (selected by the `opts` shape — the "one tool plus
1044
+ a cheap count" of issue #62). The `operation` is EXACTLY
1045
+ `<planResourceKey>.query` — a plan resource WITH a `query` row (the
1046
+ capability registry is the closed set; a special op, a `.count` row, or an
1047
+ unknown resource is refused pre-request, naming `autotask_catalog` — zero
1048
+ wire):
1049
+
1050
+ | mode | when | wire |
1051
+ |-------|---------------------------------------------|-----------------------------------------------------------------------|
1052
+ | `count` | NEITHER `groupBy` NOR `sum` | ONE `POST /V1.0/<Entity>/query/count` (the vendor count endpoint — no walk) |
1053
+ | `group` | `groupBy` given (with or without `sum`) | the house server-side-paginated `queryBounded` walk (page size `min(500, limit)`; the 1000-page guard) + the client-side fold |
1054
+ | `sum` | `sum` only | the same walk + fold (the single empty-key group carries the sums) |
1055
+
1056
+ - **`filter`** = the query-bag filter grammar (section 4: 13 leaf operators +
1057
+ and/or grouping; caps 500 OR-conditions / 1 UDF / 8000 chars; field names in
1058
+ vendor casing via `operations.fieldCatalog`). An empty array / absent = the
1059
+ unfiltered call (the vendor-accepted no-filter shape rides the wire as
1060
+ `filter: []`; that is also the blind-predicate's stamp case).
1061
+ - **`groupBy` / `sum`** = exact vendor field names (validated against the
1062
+ resource's vendored field set pre-request — a hallucinated field is a shape
1063
+ error, not a silent fold-miss; the complete problem list in one typed error,
1064
+ zero wire). Non-numeric/absent `sum` values contribute 0 (documented).
1065
+ - **`top`** (default 25, clamped 1..100) keeps the top-N groups (descending
1066
+ count; the deterministic group-key order breaks ties). **`limit`** (default
1067
+ 2500 = the house `BOUNDED_QUERY_MAX_LIMIT`, clamped down to it) is the
1068
+ total-record cap for the walk modes.
1069
+ - **count mode — the blind-count marking:** the S12-C blind-surface sentinel
1070
+ rides through the base `count` wrap (filter-aware: a plan-pinned
1071
+ `unfiltered-blind` surface stamps a 0 ONLY when the call is unfiltered — a
1072
+ FILTERED 0 is a true zero, never a sentinel; a non-zero count is the plain
1073
+ number). Result: `{ mode: 'count', count, blind, blindSurface? }` — a 0 on a
1074
+ plan-pinned blind shape is marked `blind: true` with the typed sentinel,
1075
+ never a silent absence.
1076
+ - **group/sum mode — the truncation honesty:** the walk's stamps ride through
1077
+ (`blindSurface` / `emptyReason` on a 0-row walk); `hasMore` = the walk's
1078
+ truncation flag — when true, `groups`/`sums` cover the RETURNED rows ONLY
1079
+ (no silent partial sums); `totalScanned` = the walk's fetched record count;
1080
+ `totalGroups` = the distinct groups found and `groupsTruncated` = `top` dropped some (an absent
1081
+ and an explicit-null group field fold into one group keyed `null`).
1082
+ A mid-walk vendor `429` propagates as the typed `RATE_LIMITED` error (the
1083
+ partial fold is discarded — the error IS the answer). Zero mutating.
1084
+
1085
+ ```ts
1086
+ import { aggregate } from 'node-autotask/operations';
1087
+ // the cheap count — one vendor /query/count call (no walk):
1088
+ const n = await aggregate(client, 'billing_items.query', [{ field: 'companyID', op: 'eq', value: 30132785 }]);
1089
+ // the fold — the group-by the vendor has no endpoint for (bounded walk + client-side fold):
1090
+ const fold = await aggregate(client, 'time_entries.query', [{ field: 'dateWorked', op: 'gte', value: '2026-09-01' }], { groupBy: ['ticketID'], sum: ['hoursWorked'], top: 10 });
1091
+ ```
1092
+
820
1093
  ## 8. Resource surface (128 resources)
821
1094
 
822
1095
  <!-- BEGIN GENERATED:api-per-entity -->
@@ -826,12 +1099,12 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
826
1099
  |--------------|-----------|------|------|--------|----------------------|-------------|---------|
827
1100
  | `appointments` | Appointment | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_appointment_get |
828
1101
  | `billing_codes` | BillingCode | entity | extended | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_billingCode_get |
829
- | `billing_items` | BillingItems | entity | extended | no | query, count, get, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_billingItems_get |
1102
+ | `billing_items` | BillingItems | entity | extended | no | query, count, get, update, resolve | customRange, getEntityInfo, getFieldInfo, getUdfInfo, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, today, yesterday | autotask_billingItems_get |
830
1103
  | `change_request_links` | ChangeRequestLink | entity | extended | no | query, count, get, create, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_changeRequestLinks_get |
831
1104
  | `checklist_libraries` | ChecklistLibrary | entity | extended | no | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibrary_get |
832
1105
  | `checklist_library_checklist_items` | ChecklistLibraryChecklistItem | entity | extended | yes (checklist_libraries) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibraryChecklistItem_get |
833
1106
  | `classification_icons` | ClassificationIcon | entity | advanced | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_classificationIcon_get |
834
- | `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, searchByDomain, searchByIdentity, upsertByName | autotask_company_get |
1107
+ | `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, searchByDomain, searchByIdentity, unbilledWork, upsertByName | autotask_company_get |
835
1108
  | `company_alerts` | CompanyAlert | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyAlert_get |
836
1109
  | `company_locations` | CompanyLocation | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyLocation_get |
837
1110
  | `company_notes` | CompanyNote | entity | extended | yes (companies) | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyNote_get |
@@ -881,7 +1154,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
881
1154
  | `expense_reports` | ExpenseReport | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_expenseReport_get |
882
1155
  | `holiday_sets` | HolidaySet | entity | advanced | no | query, count, get, create, update, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_holidaySet_get |
883
1156
  | `holidays` | Holiday | entity | advanced | yes (holiday_sets) | query, count, get, create, update, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_holiday_get |
884
- | `invoices` | Invoice | entity | extended | no | query, count, get, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo, markupHtml, markupXml, pdf | autotask_invoice_get |
1157
+ | `invoices` | Invoice | entity | extended | no | query, count, get, update, resolve | customRange, getEntityInfo, getFieldInfo, getUdfInfo, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, markupHtml, markupXml, pdf, today, yesterday | autotask_invoice_get |
885
1158
  | `notification_history` | NotificationHistory | entity | advanced | no | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_notificationHistory_get |
886
1159
  | `opportunities` | Opportunity | entity | extended | no | query, count, get, create, update, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunity_get |
887
1160
  | `opportunity_attachments` | OpportunityAttachment | entity | extended | yes (opportunities) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunityAttachment_get |
@@ -941,7 +1214,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
941
1214
  | `ticket_secondary_resources` | TicketSecondaryResource | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketSecondaryResources_get |
942
1215
  | `ticket_webhooks` | TicketWebhook | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketWebhook_get |
943
1216
  | `tickets` | Ticket | entity | core | no | query, count, get, create, update, resolve, search, getContext | changeStatus, findByTicketNumber, fullContext, getEntityInfo, getFieldInfo, getUdfInfo, openWithNoteAndAssign, slaHealthCheck, summary, timeline | autotask_ticket_get |
944
- | `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | createForResource, createIfNotExists, customRange, getEntityInfo, getFieldInfo, getPosted, getUdfInfo, getUnposted, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, postedWithin, today, yesterday | autotask_timeEntry_get |
1217
+ | `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | createForResource, createIfNotExists, customRange, dateWorkedWindow, getEntityInfo, getFieldInfo, getPosted, getUdfInfo, getUnposted, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, postedWithin, today, yesterday | autotask_timeEntry_get |
945
1218
  | `time_entry_attachments` | TimeEntryAttachment | entity | extended | yes (time_entries) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_timeEntryAttachment_get |
946
1219
  | `time_off_requests` | TimeOffRequest | entity | extended | no | query, count, get, resolve | approve, getEntityInfo, getFieldInfo, getUdfInfo, reject | autotask_timeOffRequest_get |
947
1220
  | `inventory_products` | InventoryProduct | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo, stockedItemsAdd, stockedItemsRemove, stockedItemsTransfer | autotask_inventoryProduct_get |
@@ -956,6 +1229,37 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
956
1229
  128 resources (121 entity + 7 integration). Rows are generated from the registry — do not hand-type.
957
1230
  <!-- END GENERATED:api-per-entity -->
958
1231
 
1232
+ **`companies.search` include_family (issue #59).** `companies.search` is
1233
+ the contains channel for fuzzy names, abbreviations and family lookups
1234
+ (exact match is the resource's `resolve`). The closed opts bag is
1235
+ `{ limit?: number (1-500, default 25), includeFamily?: boolean }` — an
1236
+ unknown key is refused pre-request (zero wire, the valid keys named). With
1237
+ `includeFamily: true` each match gains a `family` block: `parentID` (the
1238
+ match's own `parentCompanyID` — zero extra wire: the field is on the
1239
+ returned row; `null` when absent), `childIDs` (ONE bounded single-page query
1240
+ per match — `parentCompanyID` eq, capped at 50 with the `childIDsTruncated`
1241
+ flag when that page came back full with a live next cursor — a short page,
1242
+ or a full page with a null cursor, is complete) and `blindNote` (the
1243
+ plan-pinned blind-surface caveat: `companies.query` is `unowned-blind` — the
1244
+ family, and the search hits themselves, are scoped to the caller-visible
1245
+ enumeration — a family is reported as SEEN BY THIS API USER, never as
1246
+ complete). Wire cost: 1 (the search) + matches × 1 (the child queries) —
1247
+ with the default limit 25 that is ≤ 26 wire; lower `limit` for large
1248
+ candidate sets. Default callers (no `includeFamily`) see bit-for-bit the
1249
+ existing behaviour (no `family` key on the rows).
1250
+
1251
+ **The resolve miss channel (issue #59).** `resolve` is exact match only — a
1252
+ complete non-match returns `null` (resolve never widens to contains; the
1253
+ resource's search op is the contains-candidates channel). The two resolve
1254
+ ERROR outcomes carry a `suggestedAction` naming the channel (generic on the
1255
+ floor — for companies it reads `companies.search`): `RESOLUTION_AMBIGUOUS`
1256
+ (narrow the identifier to the full exact value, or run the resource's
1257
+ `search` — contains match — for candidate records to disambiguate) and
1258
+ `RESOLUTION_TRUNCATED` (the unverified miss — the opted-in
1259
+ `resolution.scanOnMiss` client scan tripped the caps without completing; retry with a more specific identifier, or
1260
+ narrow the search to fewer records — contains candidates: the resource's
1261
+ `search`).
1262
+
959
1263
  ### 8.1 Tasks — scoped mirrors and the out-of-scope task-adjacent entities (QoL note)
960
1264
 
961
1265
  `Projects/{id}/Tasks` and `ServiceCalls/{id}/Tasks` are read-only scoped
@@ -1207,8 +1511,8 @@ retrying with the same body fails identically; fix the field instead
1207
1511
  (`src/errors.ts` sets `retryable: true` for `SERVER_ERROR` and for every
1208
1512
  httpStatus >= 500). **Resolution (issue #23):** the *deterministic*
1209
1513
  validation 500 is no longer labelled retryable — a 500 whose `errors[]` text
1210
- matches the documented heuristic phrases (`isDeterministicVendor500`,
1211
- `exceeds maximum length` to start) makes exactly ONE upstream call (the
1514
+ matches the documented heuristic phrases (`isDeterministicVendor500`:
1515
+ `exceeds maximum length`, `Missing Required Field`, `Value does not exist for the required field`) makes exactly ONE upstream call (the
1212
1516
  transport skips the retry), keeps the `ServerError` class + `SERVER_ERROR`
1213
1517
  code + status 500, but carries `retryable: false`, `details.deterministic:
1214
1518
  true`, and a `suggestedAction` pointing at the field catalog
@@ -1511,6 +1815,23 @@ into one predicted-failure-free attempt.
1511
1815
  re-verified 2026-10-06: 12 unposted rows in the 2026-10-01..06 window —
1512
1816
  also the live evidence for the visibility class: unfiltered query 0 rows,
1513
1817
  windowed read has data).
1818
+ - **`truncated` + the `dateWorkedWindow` alias + `tz` (issue #66)** — the
1819
+ result is now `{ items, postedCount, unpostedCount, window, truncated }`:
1820
+ `truncated` is true when the bounded read came back with a live
1821
+ `pageDetails.nextPageUrl` (more matching rows exist; `requestCount` is the
1822
+ MaxRecords echo, not a total, so it cannot carry this signal) —
1823
+ the posted/unposted counts then cover the returned rows ONLY (at most
1824
+ `limit`); re-query with a wider window or a narrowing filter to reconcile
1825
+ the totals. `time_entries.dateWorkedWindow(from, to, opts?)` is the
1826
+ clearer-named alias: the same wire, the same result (incl. `truncated`),
1827
+ its own op identity (plan row / MCP tool
1828
+ `autotask_timeEntry_date_worked_window` / operation label) — the name
1829
+ names the `dateWorked` field the window is on; `postedWithin` keeps its
1830
+ name and default semantics. Both accept `opts.tz` (an IANA zone): the
1831
+ `from`/`to` bounds become the LOCAL calendar days in that zone (from-day
1832
+ start / to-day end, converted to UTC for the wire); omitted `tz` = UTC
1833
+ days, the current behaviour bit-for-bit. The same `tz` opt is on every
1834
+ date-window op — see §11.22.
1514
1835
  - **Drifted-create marker recovery (issue #13)** — where the plan pins a
1515
1836
  `responseDrift` row (the pinned resource: `ticket_checklist_libraries`,
1516
1837
  the W-A7 drift: the vendor answers 200 with a body that is not
@@ -1545,12 +1866,13 @@ reality*), so the point-in-time pins (`operations.createGuidance`, the
1545
1866
  `zoneFacts` lists) are only as fresh as their `verifiedAt`. The live
1546
1867
  channel is the refresh side of that pair:
1547
1868
 
1548
- **Config (off by default):** `fieldCache: { ttlMs, maxEntities? }` — a
1869
+ **Config (on by default; `false` opts out):** `fieldCache: { ttlMs?, maxEntities? }` — a
1549
1870
  separate block from `cache` (different key — entity vs resource+id —,
1550
1871
  different lifetime, different invalidation signal: drift vs write). With
1551
- it unset there is no `FieldCache` instance at all and the
1872
+ it `false` there is no `FieldCache` instance at all and the
1552
1873
  `getEntityInfo` / `getFieldInfo` / `getUdfInfo` trio is the uncached wire
1553
- path, bit-for-bit. When set, the trio goes through one private helper
1874
+ path, bit-for-bit; absent, the default instance is built and the trio
1875
+ goes through one private helper
1554
1876
  (`BaseResource.cachedMeta`, beside `cachedGet`) that stores the
1555
1877
  UNWRAPPED value, never caches an EMPTY result (an empty is not an answer
1556
1878
  — a transient vendor hiccup must not become a permanent lie), de-dupes
@@ -1568,7 +1890,9 @@ record write says nothing about the field catalog and never invalidates;
1568
1890
  the TTL is the other expiry path.
1569
1891
 
1570
1892
  **The on-demand op:** `operations.fieldCatalog(client, opts?)`
1571
- (`{ resources?, include?, fresh? }` — default: the five workflow
1893
+ (`{ resources?, include?, fields?, labels?, fresh? }` — `fields` filters the
1894
+ fields-kind rows to the named fields and reports `unmatchedFields`; `labels`
1895
+ adds the picklist labels; default: the five workflow
1572
1896
  resources, `include: ['fields']`; the closed opts bag is documented in
1573
1897
  the registry record). One bounded entityInformation GET per resource ×
1574
1898
  kind; the wire cost `resources × include` is REFUSED pre-request above
@@ -1585,12 +1909,18 @@ a known key without a catalog → `CONFIG_ERROR`, an unknown key → typed
1585
1909
  **Honest scope (stated in the served text AND the result's `honesty`
1586
1910
  block):** the live catalog reports a field's SHAPE — existence,
1587
1911
  requiredness, type, `maxLength`, read-only, queryable, picklist-ness —
1588
- and serves picklist VALUES where the vendor exposes them. It CANNOT
1589
- resolve a picklist VALUE the vendor does not expose to this API user
1590
- (the W-A4 wall — e.g. the attachments `Type` picklist): retrying the
1591
- catalog will not surface a withheld value; that gap is vendor-side. The
1592
- `honesty.picklistValues` field reports `partial` when any served row
1593
- carries exposed values, `unexposed` otherwise.
1912
+ as FLAGS and NAMES; without the opt-in `labels: true` it serves no
1913
+ picklist VALUES at all. With `labels: true` the projected picklist rows
1914
+ (the `fields`-kind and `udf`-kind rows) carry the vendor's exposed
1915
+ picklist VALUES — the raw vendor entries `{ value, label, parentValue?, isActive?, ... }` (`value` a string)
1916
+ as-is, zero transform; the `labels` result flag names what the payload
1917
+ carries; the values ride the same cached channel, zero extra wire. It
1918
+ CANNOT resolve a picklist VALUE the vendor does not expose to this API
1919
+ user (the W-A4 wall — e.g. the attachments `Type` picklist): retrying
1920
+ the catalog will not surface a withheld value; that gap is vendor-side.
1921
+ The `honesty.picklistValues` field reports the vendor's WIRE exposure
1922
+ (`partial` when any raw row carried exposed values, `unexposed`
1923
+ otherwise) — informational, orthogonal to the `labels` opt.
1594
1924
 
1595
1925
  Served as `autotask_field_catalog` (extended tier — the 15-tool core
1596
1926
  surface is untouched; the tool is reachable through `autotask_read` /
@@ -1608,13 +1938,16 @@ human timescale (countries, departments, roles, billing codes, services)
1608
1938
  and records that change minute to minute (tickets, time entries). The
1609
1939
  override makes the choice per resource:
1610
1940
 
1611
- **Config (additive; the off-by-default stance stands):** `cache: { ttlMs,
1612
- perResourceTtlMs?: Record<string, number> }` — every value is a positive
1613
- integer, validated fail-closed in `resolveConfig` exactly like `ttlMs`
1614
- (wrong type / non-integer / non-positive → `AutotaskConfigError` at
1615
- construction). With the map absent there is NO behaviour change: every
1616
- resource expires on `ttlMs` (the R-F3a off-by-default contract and the
1617
- existing cache pins stand bit-for-bit).
1941
+ **Config (the default map; `false` opts out):** `cache: { ttlMs?,
1942
+ perResourceTtlMs?: Record<string, number> } | false` — every value is a
1943
+ positive integer, validated fail-closed in `resolveConfig` exactly like
1944
+ `ttlMs` (wrong type / non-integer / non-positive → `AutotaskConfigError`
1945
+ at construction). Issue #80: the plan-derived table
1946
+ `DEFAULT_PER_RESOURCE_TTL_MS` IS the default per-resource map — with the
1947
+ operator map absent, every plan resource rides its table row (the 1h
1948
+ reference class / the 5min default class) and a plan-unnamed path expires
1949
+ on `ttlMs`; operator entries OVERRIDE table entries per resource (the
1950
+ effective map is operator entry > table entry > `ttlMs`).
1618
1951
 
1619
1952
  **The key contract (pinned):** the entry's TTL is the override for the
1620
1953
  **plan key of the entity the read path points to** — the cache key is
@@ -1790,10 +2123,11 @@ config surface, no new ops) and pins the two hazards the issue names:
1790
2123
  the record after the not-found) drops the stale marker and serves the
1791
2124
  record — a record that exists is never reported absent while a live
1792
2125
  positive read of it sits in the cache (pinned). The dry-run parent-
1793
- exists check shares this mechanism: for a genuinely absent parent it
1794
- re-derives the same verdict the base wire call would (zero wire when
1795
- the marker is live), and a positive parent read clears the marker for
1796
- it too. The
2126
+ exists check does NOT share the marker (issue #80 — with the cache
2127
+ default-on, a stale marker or a stale positive entry would corrupt the
2128
+ verdict): it reads the parent FRESH (bypass + wire), so a vendor 404
2129
+ re-derives the same verdict the base wire call would and a stale marker
2130
+ cannot suppress the read. The
1797
2131
  documented trade: a record created elsewhere may serve 'absent' until the
1798
2132
  marker TTL (or a write through this client lands) — the opt-in staleness
1799
2133
  contract, same trade as the positive entries.
@@ -1801,8 +2135,16 @@ config surface, no new ops) and pins the two hazards the issue names:
1801
2135
  client clears the whole read cache (the existing transport behaviour),
1802
2136
  covering the FK parts and the negative marker together — the "someone
1803
2137
  changed a company name" case.
1804
- - **Off by default stands.** With `cache` unset every composite call is
1805
- bit-for-bit the pre-#36 wire and result.
2138
+ - **Default-on since #80.** With `cache: false` every composite call is
2139
+ bit-for-bit the pre-#36 wire and result; absent, composites ride the
2140
+ default-on read cache (positive entries AND the absent markers).
2141
+
2142
+ - **Opt-in names/labels ride these same channels (issue #77).** The
2143
+ composites' opt-in `names` siblings resolve over the existing
2144
+ default-on channels (the name cache / the ReadCache / the fieldCache —
2145
+ no new cache) with a separate name-read budget (`maxNameReads`,
2146
+ default 20, lowerable never raised) — see section 11.21 for the
2147
+ per-field-class wiring.
1806
2148
 
1807
2149
  Pins: `test/resources/context-fk-cache.test.ts` (10 pins, mock wire only).
1808
2150
 
@@ -1961,6 +2303,304 @@ Pins: `test/config-host-policy.test.ts` (10 pins — the exported helper is
1961
2303
  CONSUMED by the tests, never re-implemented; mock wire only, no vendor
1962
2304
  wire).
1963
2305
 
2306
+ ### 11.21 Resource name expansion and the name cache (issue #65, default-on since #80)
2307
+
2308
+ `operations.expandResourceNames` (section 7.7) resolves vendor numeric
2309
+ resource ids to display names with PER-ID `resources.get` reads ONLY.
2310
+ Its channel is `resourceNameCache: { ttlMs?, maxEntries? }` — ON by
2311
+ default (issue #80: absent builds the default instance, 1 h / 1024
2312
+ entries); `resourceNameCache: false` is the uncached per-id path,
2313
+ bit-for-bit (per-call de-duplication is unconditional).
2314
+
2315
+ **The channel** (`src/resourceNameCache.ts` — a THIRD cache channel
2316
+ beside `cache` and `fieldCache`; a separate channel, NOT shared with
2317
+ either):
2318
+
2319
+ - **Key space**: `resources:<id>` (the canonical id). Disjoint from the
2320
+ ReadCache (wire-PATH keys) and the FieldCache (`resource:kind` keys —
2321
+ the closed three-kind metadata set). The namespace leaves a documented
2322
+ path to future FK tables — v1 serves `resources` only.
2323
+ - **What is stored**: exactly the two deterministic vendor answers — the
2324
+ resolved name `{ name, blind: false }` and the vendor's TRUE not-found
2325
+ blind marker `{ name: null, blind: true }` (the issue-#36
2326
+ absent-marker precedent: a repeat miss costs no wire). A 200 WITH a
2327
+ record but NO name part is NOT stored (a transient partial record
2328
+ must not stick for the whole TTL). A FAILURE is never cached
2329
+ (a fault rejects before any `set` can run).
2330
+ - **In-flight de-duplication**: concurrent callers for the same id share
2331
+ ONE wire request (the promise is dropped on settle, success OR
2332
+ failure — the fieldCache two-armed pattern).
2333
+ - **Cap / TTL**: `ttlMs` an optional positive integer, default 1 h (fail-closed
2334
+ `AutotaskConfigError`); `maxEntries` default 1024
2335
+ (`RESOURCE_NAME_CACHE_MAX_ENTRIES`); past the cap → purge expired
2336
+ first, then evict the oldest-inserted (lazy purge on read).
2337
+ - **Scoped invalidation**: a real write to the `resources` resource
2338
+ through the same client (a rename via this client) clears the channel;
2339
+ a write to ANY OTHER resource never invalidates (it says nothing about
2340
+ names — the fieldCache "an ordinary record write never invalidates"
2341
+ rule, scoped variant). The existing ReadCache all-write clear is
2342
+ UNCHANGED.
2343
+ - **Coexistence with the ReadCache** (when BOTH are configured): the
2344
+ op's per-id `get` rides the ReadCache transparently (a ReadCache hit
2345
+ inside its TTL costs zero wire and the name is then stored in the name
2346
+ cache — double layering, disjoint key spaces). A `resources` write
2347
+ clears BOTH; a write to another resource clears only the ReadCache. A
2348
+ `fresh: true` call bypasses BOTH channels and refreshes both (the
2349
+ per-id GET fetches live — issue #77: a rename made elsewhere is served
2350
+ on the very next fresh call, never a stale snapshot).
2351
+ - **Zero interaction with the #64 field-cache surface**: `FIELD_CACHE_KINDS`
2352
+ stays the closed `['info','fields','udf']`; the fieldCatalog bag, its
2353
+ honesty wording and the `changeStatus` live guard and its
2354
+ `getFieldInfo` path are bit-for-bit untouched. The only shared code is
2355
+ the `http.request` completion hook, which gains ONE
2356
+ scoped-invalidation line (the fieldCache drift block and the readCache
2357
+ all-write clear are unmodified).
2358
+ - **NO new row in `src/cache-ttl-tables.ts`** (stated so the absence is
2359
+ not hunted): the artifact's row set is pinned at exactly one row per
2360
+ PLAN RESOURCE, and issue #65 adds a plan OPERATION, not a plan
2361
+ resource. The rows — including `resources: 300000`, the ReadCache
2362
+ per-resource TTL REFERENCE for `resources.get` record reads — are
2363
+ unchanged; the name channel's TTL is the operator's
2364
+ `resourceNameCache.ttlMs`, not the table's row. Only the artifact's
2365
+ `planHash` header line re-stamps (the plan bytes move).
2366
+
2367
+ **Staleness contract**: a resource renamed OUTSIDE this client (the UI,
2368
+ another integration) serves its old name until `ttlMs` expires (or a
2369
+ local `resources` write clears the channel) — the exact staleness trade
2370
+ every house cache documents ("a cached read is a snapshot AT FETCH
2371
+ TIME"). A `fresh: true` call closes the gap immediately: it bypasses
2372
+ BOTH the name cache and the ReadCache and refreshes both, so the rename
2373
+ is served on the very next fresh call (issue #77).
2374
+
2375
+ **Composite wiring (issue #77)**: the full-context composites
2376
+ (`tickets.fullContext`, `companies.fullProfile`,
2377
+ `configuration_items.profile`, `projects.getContext`) accept an
2378
+ additive `names?: boolean` opt (default OFF — the sibling keys are
2379
+ ABSENT from every served row and no name/label resolution runs, so the
2380
+ output is byte-identical) + `maxNameReads?: number` (default 20,
2381
+ lowerable, never raised). With `names: true`, the siblings ride the
2382
+ EXISTING default-on channels — no new cache: resource ids (creator /
2383
+ owner / lead / the time-entry resource) resolve through
2384
+ `operations.expandResourceNames` (name cache → ReadCache → one wire read
2385
+ per distinct uncached id); record-FK names (role / billing code /
2386
+ contract) ride the per-resource single-record gets (ReadCache, the
2387
+ per-resource TTLs, the #36 absent-marker contract untouched); the CLOSED
2388
+ picklist scope (owner-approved list: the ticket fields
2389
+ `status` / `priority` / `ticketType` / `issueType` / `subIssueType` /
2390
+ `source` / `queue` ONLY — project / company picklists stay label-less)
2391
+ rides ONE `operations.fieldCatalog` `labels: true` call (zero extra wire
2392
+ on a warm fieldCache). The separate name-read budget counts COLD wire
2393
+ name reads only (cache hits are free of it); over-budget ids serve
2394
+ `name: null, capped: true` on the composite's name row + the top-level
2395
+ `truncated` marker — never an error. An unresolvable id serves the blind
2396
+ marker (`name: null, blind: true`); a picklist whose values the vendor
2397
+ does not expose to this API user serves `null` + the fieldCatalog
2398
+ reason row (the #64 W-A4 wall) — never an error. A picklist that IS served but does not
2399
+ contain the record's value serves `null` + a different reason row
2400
+ (`PICKLIST_VALUE_NOT_SERVED_NOTE`: an inactive or unexposed value) so the two conditions can be
2401
+ told apart. The `names` section
2402
+ (present on the result EXACTLY when `names: true`) carries the per-id
2403
+ rows and the reason rows.
2404
+
2405
+ **AC mapping**: AC1 (opt-in; the explicit-`false` output unchanged) —
2406
+ the op is invoked only on request and the channel is default-on (issue
2407
+ #80; `resourceNameCache: false` keeps the uncached per-id path
2408
+ bit-for-bit — the explicit-`false` pins are that proof). AC2 (each
2409
+ distinct id fetched at most once per cache lifetime) —
2410
+ per-call de-duplication is ALWAYS on (zero-config); with the channel
2411
+ configured, a repeat id (resolved OR blind) inside the TTL costs no
2412
+ wire; a failure is never cached (the AC governs the cache's stored
2413
+ answers, not faults). AC3 (the invisible-table case) — a per-id TRUE
2414
+ not-found → `{ name: null, blind: true }`, the id kept, no error, no
2415
+ throw, the op RESOLVES with the summary counts. AC4 (no `id in`) — the
2416
+ only wire verb is the per-id GET; the module imports no query builder
2417
+ (static guarantee) and the tests assert METHOD + PATH on every wire
2418
+ call.
2419
+
2420
+ Pins: `test/operations/expandResourceNames.test.ts` (11 pins — the plan
2421
+ row's `tests[]`) + `test/resource-name-cache.test.ts` (7 pins — the
2422
+ class unit + the client-option wiring + the scoped invalidation; mock
2423
+ wire only, synthetic data, no vendor wire).
2424
+
2425
+ ### 11.22 Timezone-anchored date windows for billing items and invoices (issue #66)
2426
+
2427
+ The time-entry window helpers pinned their bounds to UTC (the API is UTC)
2428
+ while the node computes them in the configured timezone, and billing items /
2429
+ invoices had no window helpers at all. Issue #66 makes the zone EXPLICIT and
2430
+ additive, and mirrors the family:
2431
+
2432
+ - **Two new window families** — the full mirror of the time-entry helpers
2433
+ (the same 12 ops each: `today`, `yesterday`, `last24h`, `last7`, `last14`,
2434
+ `last30`, `last90`, `lastFullWeek`, `lastFullFortnight`, `lastFullMonth`,
2435
+ `lastFullQuarter`, `customRange`):
2436
+ - `billing_items.<kind>(opts?)` — ONE bounded server-filtered query on
2437
+ the billing item's date field **`itemDate`**
2438
+ (`POST /V1.0/BillingItems/query`) → `Promise<BillingItems[]>`;
2439
+ - `invoices.<kind>(opts?)` — ONE bounded server-filtered query on the
2440
+ invoice's date-time field **`invoiceDateTime`**
2441
+ (`POST /V1.0/Invoices/query`) → `Promise<Invoice[]>` (the invoices
2442
+ model has no plain `invoiceDate` — `fromDate`/`toDate` are range
2443
+ fields, not the invoice date).
2444
+ - **The zone contract (shared)** — `opts.tz` takes an explicit IANA zone
2445
+ (e.g. `'Australia/Sydney'`); OMITTED `tz` = UTC, the current behaviour
2446
+ bit-for-bit (the regression pin). With a zone, the window's calendar
2447
+ day/period is computed in that zone's LOCAL calendar and converted to UTC
2448
+ for the wire (vendor filter values stay second-precision UTC ISO). DST is
2449
+ handled by inverting local midnights to UTC instants (a local day that
2450
+ gains or loses an hour still pins both boundaries to its TRUE midnights —
2451
+ never clock-hour arithmetic); `last24h` is rolling (`now − 24h → now`)
2452
+ and TZ-invariant by construction. A zone `Intl.DateTimeFormat` rejects is
2453
+ refused pre-request as a typed `CONFIG_ERROR` (zero wire). There is no
2454
+ config-level timezone in v1 (the zone is per-call; a future
2455
+ `config.timeZone` default would be an additive follow-up).
2456
+ - **The existing family gains the same opt** — the 12 `time_entries`
2457
+ window ops (incl. `customRange`) plus `postedWithin` and its
2458
+ `dateWorkedWindow` alias accept `opts.tz`
2459
+ with the identical contract, so the whole window family is uniformly
2460
+ timezone-anchored (default UTC unchanged).
2461
+ - **Shape and wire** — each op is exactly ONE bounded query (`MaxRecords`
2462
+ 1-500, default 25; the `itemDate` / `invoiceDateTime` `gte`/`lte` filter
2463
+ rides the wire), and the result is the plain `items[]` array — no counts
2464
+ (the time-entry family shape).
2465
+
2466
+ ### 11.23 Free-text caps and includeFields presets (issue #61)
2467
+
2468
+ Issue #61 (the library remainder after the server-side split — TSV /
2469
+ `output_path` / the untrusted-wrapper opt-out live in the server repo)
2470
+ adds two opt-in keys to the bounded-query bag (section 4), both
2471
+ query-verb-ONLY and zero-wire or vendor-column-selection-only:
2472
+
2473
+ - **`noteChars: N` — the free-text cap.** One integer (>= 1) caps EVERY
2474
+ string value in the resource's untrusted-class free-text fields (the
2475
+ generated `UNTRUSTED_FIELDS_BY_RESOURCE` set — the SAME plan column the
2476
+ projection wraps) to `N` chars + the explicit marker
2477
+ `… [+N chars truncated]` (N = chars removed). Truncation is at
2478
+ result-build, copy-on-truncate (input records are never mutated), and
2479
+ runs BEFORE the server adds its untrusted wrapper (section 12's
2480
+ ordering note). Additive result field: `noteCharsTruncated` (the
2481
+ truncated-field count; present only when > 0). Absent = the raw result
2482
+ bit-for-bit.
2483
+ - **`fieldsPreset: name` — the closed preset list.** A preset is
2484
+ `{ resource, fields }` — hand-curated (NOT plan-derived: a curation
2485
+ choice, same class as n8n-mcp.curation.json), resolved pre-walk to the
2486
+ walk's `includeFields` (the vendor's column selection; `id` always
2487
+ appended). The v1 list is CLOSED at the one preset the issue names:
2488
+
2489
+ | preset | resource | fields (14) |
2490
+ |---|---|---|
2491
+ | `billing_join` | `billing_items` | `companyID, ticketID, projectID, timeEntryID, contractServiceID, contractID, billingCodeID, expenseItemID, itemDate, quantity, rate, extendedPrice, totalAmount, nonBillable` |
2492
+
2493
+ `billing_join` is the billing-item join read: the FK columns that join a
2494
+ billing item to its company / ticket / project / time entry / contract
2495
+ service / contract / billing code / expense item, plus the date and the
2496
+ amount fields — the "common narrow read" the issue's example targets.
2497
+ - **The live check (the AC test for the presets).** Each preset's fields
2498
+ are asserted to EXIST on the live resource against the LIVE field
2499
+ catalogue (`operations.fieldCatalog` — the entityInformation/fields
2500
+ channel; the #64 fieldCache idiom), NOT against `MODEL_FIELD_SETS`
2501
+ (the vendored-spec projection — a cheap NON-AUTHORITATIVE subset guard
2502
+ only; the spec can lag the live API). The check runs in the live round
2503
+ (`npm run live-smoke` — read-only, one field-catalogue read per preset;
2504
+ a missing field FAILS the run), not in `npm test`. The v1 field list's
2505
+ LIVE existence is verified there — the offline proof is the spec-subset
2506
+ guard only (the review correction's mandate, the `fetchMany.liveFields`
2507
+ live-first idiom).
2508
+ - **Refusals (pre-request, zero wire, typed):** unknown preset name (the
2509
+ valid names are named — the closed-set discipline), preset/resource
2510
+ mismatch (the preset and its resource are named), `fieldsPreset` + an
2511
+ explicit `includeFields` (both are named — no silent precedence), and
2512
+ `noteChars` `0` / negative / non-integer.
2513
+ - **Backwards compatibility:** both keys are OPT-IN on a closed bag —
2514
+ absent = the pre-#61 path bit-for-bit (the default-path zero-move is
2515
+ pinned by the note-chars sidecar tests). New public exports:
2516
+ `FIELD_PRESETS`, `FIELD_PRESET_NAMES`, `resolveFieldsPreset` and the
2517
+ `FieldsPreset` type (additive; the public-surface gate passes as
2518
+ additive-only). `UNTRUSTED_FIELDS_BY_RESOURCE` and
2519
+ `noteCharsTruncationMarker` are internal. `src/untrusted.ts` is byte-identical
2520
+ (the projection contract is untouched — the cap is a query-path
2521
+ mechanism, not a projection change).
2522
+
2523
+
2524
+ ### 11.24 The unbilled-work composite and the generic aggregate (issue #62)
2525
+
2526
+ Issue #62 adds the two tools the issue names, both additive reads (the 0.9.0
2527
+ release carries them):
2528
+
2529
+ - **`companies.unbilledWork(id, opts?)` — the unbilled-work composite.** One
2530
+ call for a company's posted-but-uninvoiced work: its billing items (per
2531
+ `source`), the time behind them, the per-ticket rollup, the unposted
2532
+ anti-join section, ex-tax totals + hours, and the billing-vs-time warnings.
2533
+ The join path is the correction's core: **the vendored time_entries model
2534
+ carries NO company field** — the join goes through the company's tickets
2535
+ (`ticketID`) AND the company's projects' tasks (`taskID`; the vendored tasks
2536
+ model carries no companyID/ticketID, so tasks are reached via the company's
2537
+ projects). `source` is the closed 4-set: `posted_uninvoiced` (default —
2538
+ no `invoiceID` — `notExist`, the uninvoiced mark; live-verified that `eq 0` matches nothing), `invoice` (`invoiceID = invoiceId`;
2539
+ the invoice record itself is NOT fetched in v1 — the items already carry
2540
+ invoiceID/itemDate/amounts), `date_range` (`itemDate` window, both dates
2541
+ required pre-request), `tickets` (`ticketID in ticketIds`; the time part
2542
+ additionally joins those tickets directly). `dateFrom`/`dateTo` narrow the
2543
+ `itemDate` window in EVERY source (and `dateWorked` on the time part).
2544
+ `includeParent` (default true) pulls ONE level via `company.parentCompanyID`
2545
+ (dangling → `parent: null`, parent parts empty — NOT an error). Per-part
2546
+ `limit` (default 50, clamped 1..500) = the part's `MaxRecords` + the capped
2547
+ lists (`warnings`, the `unposted` anti-join); each part flags `capped` /
2548
+ `hasMore` + the typed `status` (`ok` / `blind` / `skipped` — the empty
2549
+ join set SKIPS the time query with the honest note; a filtered empty part is
2550
+ a TRUE zero). The unposted section = the fetched time entries whose id is in
2551
+ no fetched billing item's `timeEntryID` (client-side anti-join, zero wire).
2552
+ Warnings: `noTimeEntry` (billing items whose `timeEntryID` is empty/0) and
2553
+ `hourDelta` (billed `quantity` ≠ entry `hoursWorked` — strict: the vendor
2554
+ data is exact decimal hours, any mismatch is real; the epsilon alternative
2555
+ is a one-line change). Totals: `preTax` = Σ `extendedPrice`, `hours` = Σ
2556
+ time-entry `hoursWorked`, `entryCount`, with `totalAmount` / `taxDollars`
2557
+ surfaced alongside (Q2 field choice); the per-ticket rollup and the totals
2558
+ derive from the SAME folded rows (one source of truth). `includeNotes`
2559
+ (default false) + `noteChars` (default 400) cap the note fields
2560
+ (`summaryNotes` / `description`) with the #75 marker BEFORE the server-side
2561
+ untrusted wrapper (section 12's ordering note; section 11.23). **Wire
2562
+ budget: 6 bounded reads without a parent (company get + tickets + projects +
2563
+ tasks + billing + time queries) and 7 with a parent; zero mutating;
2564
+ `includeNotes`/`noteChars`/`rollup` add zero wire.** The join sets are
2565
+ capped at 300 ids per `in` list (sorted, first N) so the serialised filter
2566
+ stays under the 8000-char vendor cap — a company with a huge ticket/task set
2567
+ truncates the JOIN SET (not the results) with a documented part note (Q3:
2568
+ the wire caps refuse the honest alternative; the per-query `limit` is the
2569
+ softer first bound).
2570
+ - **`operations.aggregate(operation, filter, opts?)` — the generic
2571
+ aggregate/count** (section 7.8): the count-vs-walk split — the
2572
+ groupBy/sum-absent mode is ONE vendor `POST /query/count` (the S12-C
2573
+ blind-sentinel marking; a 0 on a plan-pinned blind surface is marked blind,
2574
+ never a silent absence — AC5), while the group/sum modes walk the named
2575
+ `<resource>.query` surface (page size `min(500, limit)`; `hasMore` /
2576
+ `totalScanned` carry the truncation honesty — AC4).
2577
+
2578
+ **Acceptance mapping (the body's 6 ACs, post-correction):** AC1 the wire
2579
+ budget (the 6(+1) bounded reads, pinned by the wire-call test; the per-part
2580
+ limits + `hasMore`); AC2 the warnings contract (the `noTimeEntry` +
2581
+ `hourDelta` folds); AC3 the notes default-off + the `noteChars` cap (the #75
2582
+ marker, BEFORE the wrapper); AC4 the truncation honesty (`hasMore` /
2583
+ `totalScanned`; no silent partial sums); AC5 the blind-count marking (the
2584
+ S12-C sentinel in count mode); AC6 the zero-mutating read surface (the
2585
+ `effect: read` plan rows; the governor appends nothing).
2586
+
2587
+ ### 11.25 Explained resolve and search misses (issue #78)
2588
+
2589
+ `resolve` returns a bare `null` and `search` an empty list on a miss, which reads as a true
2590
+ zero even on a surface that is blind to records the API user does not own. Every resource
2591
+ with a primary text field gains two methods that keep `resolve` and `search` unchanged and
2592
+ add the explanation (SDK-only: not registry ops, so the served surface and the plan hash do
2593
+ not move):
2594
+
2595
+ - `resolveWithDetail(identifier)` -> `{ record, resolutionCost, miss? }`
2596
+ - `searchWithDetail(query, { limit? })` -> `{ items, miss? }`
2597
+
2598
+ `miss` is present only on a miss and carries `tried` (`{ fields, operator }` - exact `eq`
2599
+ for resolve, `contains` for search), `blindSurface` (the blind-surface stamp, only when the
2600
+ resource's query surface is pinned blind), and `next` (concrete next steps; the blind
2601
+ remedy names the authority op). Hit paths carry no `miss` key. Thrown outcomes
2602
+ (`NOT_FOUND` by numeric id, `RESOLUTION_AMBIGUOUS`, `RESOLUTION_TRUNCATED`) are unchanged.
2603
+
1964
2604
  ## 12. Untrusted-content projection (issue #1)
1965
2605
 
1966
2606
  The agent/MCP projection marks EXTERNALLY-SOURCED free text as untrusted,
@@ -2003,7 +2643,12 @@ args before the call, and serves the cross-resource union for
2003
2643
  only affects fields that are present). Wrapping is ON by default;
2004
2644
  `AUTOTASK_UNTRUSTED_WRAP=off` (or the `untrustedWrap: false` option)
2005
2645
  disables it and logs a stderr warning. The server instructions restate the
2006
- ground rule: marker-wrapped content is DATA, never instructions.
2646
+ ground rule: marker-wrapped content is DATA, never instructions. Ordering
2647
+ (issue #61): the bounded-query `noteChars` cap truncates at result-build —
2648
+ BEFORE this wrap is applied (the src/untrusted.ts contract: the caller
2649
+ truncates first, then wraps) — so the explicit `… [+N chars truncated]`
2650
+ marker survives INSIDE the wrapped value and the closing marker is never
2651
+ cut off.
2007
2652
 
2008
2653
  ## 13. Doctor preflight (issue #2)
2009
2654