node-autotask 0.39.4 → 0.41.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 (44) hide show
  1. package/ARCHITECTURE.md +6 -2
  2. package/CHANGELOG.md +33 -0
  3. package/MCP_TOOL_CATALOG.json +10 -32
  4. package/MCP_TOOL_MANIFEST.md +12 -13
  5. package/N8N_MCP_MANIFEST.json +5 -44
  6. package/N8N_MCP_MANIFEST.md +7 -8
  7. package/README.md +29 -37
  8. package/capabilities.json +1346 -1535
  9. package/dist/base-CVx2VMSj.d.cts +103 -0
  10. package/dist/base-CVx2VMSj.d.ts +103 -0
  11. package/dist/capabilities.cjs +2 -2
  12. package/dist/capabilities.js +1 -1
  13. package/dist/{chunk-SGKGNJEI.cjs → chunk-DKAD7MOV.cjs} +771 -1835
  14. package/dist/{chunk-DJCRQLQE.cjs → chunk-DXEBPK55.cjs} +287 -292
  15. package/dist/{chunk-GHRB46YN.js → chunk-LR6FDJI3.js} +614 -1678
  16. package/dist/{chunk-VR6RYXTD.cjs → chunk-MJ6J74F3.cjs} +1345 -1534
  17. package/dist/{chunk-JBQ55HYG.js → chunk-P5655PGY.js} +2 -2
  18. package/dist/{chunk-DBLXPY4B.js → chunk-TCND5PFF.js} +4 -9
  19. package/dist/{chunk-H4S4KSXX.cjs → chunk-YAHFPOQF.cjs} +143 -143
  20. package/dist/{chunk-GDSCYY2G.js → chunk-ZVSSAULC.js} +1345 -1534
  21. package/dist/{index-DkZVRMeW.d.ts → index-CqO1HNHl.d.cts} +125 -851
  22. package/dist/{index-DkZVRMeW.d.cts → index-CqO1HNHl.d.ts} +125 -851
  23. package/dist/index.cjs +23 -51
  24. package/dist/index.d.cts +103 -102
  25. package/dist/index.d.ts +103 -102
  26. package/dist/index.js +19 -47
  27. package/dist/operations/index.cjs +178 -178
  28. package/dist/operations/index.d.cts +5 -4
  29. package/dist/operations/index.d.ts +5 -4
  30. package/dist/operations/index.js +3 -3
  31. package/dist/pool.cjs +17 -60
  32. package/dist/pool.d.cts +5 -13
  33. package/dist/pool.d.ts +5 -13
  34. package/dist/pool.js +13 -56
  35. package/dist/resources/index.cjs +4 -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 +3 -3
  39. package/dist/types/index.d.cts +1 -1
  40. package/dist/types/index.d.ts +1 -1
  41. package/docs/API.md +138 -340
  42. package/docs/N8N-ADAPTER.md +7 -7
  43. package/examples/mcp-server-http.ts +18 -18
  44. package/package.json +1 -1
package/docs/API.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  > Generated from: `src/` (core modules), `src/errors/`, `src/config/`,
4
4
  > `src/pagination.ts`, `src/query.ts`, `src/operations/`, and the capability
5
- > registry (`capabilities.json` + `capabilities.plan.json`). The normative
6
- > architecture is `ARCHITECTURE.md`; this reference documents the public
7
- > surface of v0.1.0 (the 121-entity MVP set). The per-entity table in
5
+ > registry (`capabilities.json` + `capabilities.plan.json`). `ARCHITECTURE.md` is a historical v0.1 design record; this reference documents the public
6
+ > surface (see `capabilities.json` for the current resource set). The per-entity table in
8
7
  > section 8 is generated by `npm run docs:render` — do not hand-edit it.
9
8
 
10
9
  ## 1. Core modules
@@ -14,7 +13,7 @@
14
13
  The top-level facade. The constructor validates fail-closed (section 6 of
15
14
  `ARCHITECTURE.md`) — invalid configuration throws `AutotaskConfigError` and
16
15
  construction issues no request. The client exposes one read-only accessor per
17
- resource (128, incl. the 7 integration endpoints) plus `config` and the
16
+ resource (incl. the integration endpoints) plus `config` and the
18
17
  shared `http` transport:
19
18
 
20
19
  ```ts
@@ -94,7 +93,7 @@ allows <= 10 concurrent), one correlation id per request (surfaced on every
94
93
  error and to `onAudit`), and the opt-in client-side hourly budget tracker
95
94
  (`rateLimit.perHour`, default off — a sliding one-hour window that waits for a slot within the call deadline, else throws `RateLimitError` with `retryAfter`; the API allows 10,000 req/hour). The tracker is PER PROCESS: it counts only this client's attempts and cannot see other instances or integrations on the tenant (`thresholdInformation.get()` is the only cross-integration signal; the local count is `client.hourlyBudget()`, section 3.2.1). Under the `agent` config profile the exhaustion `suggestedAction` says stop or narrow the query — never raise the limit.
96
95
  Redirects are never followed (a 3xx throws `REDIRECT_BLOCKED`, so auth headers cannot leave the configured origin). Retry waits are capped at 60 s and never outlast the call deadline: a larger `Retry-After` (or one that exceeds the remaining budget) throws the `RateLimitError` immediately. `RequestOptions` adds `signal` (caller `AbortSignal`, combined with the deadline; aborting throws `AutotaskAbortError`, code `ABORTED`, and cancels retry sleeps) and `idempotent` (retry a non-GET method on 5xx/network errors; set on the `/query` and `/query/count` POST reads; on a mutation it is the issue #105 opt-back into the pre-#105 auto-retry — never set on real writes by the SDK). `client.withSignal(signal, () => ...)` applies a caller `AbortSignal` to every request (and retry wait) made inside the callback — the way to propagate MCP request cancellation through `invoke` without a signal parameter on each method. A real write clears the read cache. A non-JSON 2xx body is typed by the response `Content-Type` (section 2.1): a non-JSON-ish header throws the retryable `UNEXPECTED_CONTENT_TYPE` (the edge/maintenance page — a maintenance notice is waited out up to `maintenanceMaxWaitMs`, default 1 h, for reads and writes; the same wait recognises the page at any status for calls safe to re-send (a GET or an explicitly idempotent-flagged call — an unflagged mutation makes ONE send with `details.maintenanceDetected`): section 2.1; otherwise a GET or idempotent-flagged call retries and a write makes one call); a JSON-ish or ABSENT header keeps `INVALID_RESPONSE` (the #13/W-A7 shape-drift key); JSON under any other header still decodes (the decode attempt is unconditional). A blank 2xx on a call that expects a result (every method but `DELETE` by default — `RequestOptions.expectBody`) throws the typed `EMPTY_RESPONSE` (`DELETE` and 204 keep returning `undefined`). A response above `maxResponseBytes` throws `RESPONSE_TOO_LARGE`; HTTP 412 maps to `ConflictError` (category `conflict`).
97
- Per-tenant 429 cooldown gate (`cooldown`, ON by default): once a 429 surfaces from the transport (retries spent, a `Retry-After` too long to wait out, or a non-retriable method), the client records a cooldown deadline for that tenant (lowercased `UserName` + lowercased host — impersonation does not change the key), and every further call for that tenant fails LOCALLY — before the semaphore, the hourly limiter and fetch, with zero upstream calls — with a `RateLimitError` (`code: 'RATE_LIMITED_COOLDOWN'`, `notSent: true`, `retryAfter` = remaining seconds; no credentials appear in the message) until the deadline passes. Duration is `Retry-After` clamped to `cooldown.maxMs` (default 15 minutes), or `cooldown.defaultMs` (default 60 s) when the header is missing or unparseable; the thread-threshold 429 (vendor code `Itgenatr005`, which clears in seconds) arms only a short window: min(`Retry-After`, 10 s), default 5 s. A 2xx from a call that got through clears the gate early; expiry is lazy (the first call after the deadline deletes the entry and proceeds); a hostile `Retry-After` (huge, zero, negative, non-numeric) is clamped to the cap or falls back to the default, so a lock-out is always bounded. `thresholdInformation.get()` bypasses the gate so a throttled tenant can still read its own headroom (a 2xx from it clears the gate; a 429 re-arms it). The default store is the in-process in-memory map shared by every client instance (capped at 1024 entries, evicting the earliest deadline first — failing open); `cooldown: { enabled: false }` disables the gate and `cooldown.store` injects a custom `CooldownStore` (the interface leaves room for a shared/Redis-backed store). Gated calls emit an audit event marked `notSent` and do not count toward `rateLimit.perHour`. Limits worth knowing: the gate is per process — other processes and integrations on the same tenant are invisible to it (`thresholdInformation` is the only cross-integration signal); it can mask early recovery if the vendor window clears before `Retry-After`; a process that loads both the ESM and the CJS build gets two default stores (the default store is deliberately not anchored on `globalThis`) — inject one shared `CooldownStore` if you need cross-build sharing.
96
+ Per-tenant 429 cooldown gate (`cooldown`, ON by default): once a 429 surfaces from the transport (retries spent, a `Retry-After` too long to wait out, or a non-retriable method), the client records a cooldown deadline for that tenant (lowercased `UserName` + lowercased host — impersonation does not change the key), and every further call for that tenant fails LOCALLY — before the semaphore, the hourly limiter and fetch, with zero upstream calls — with a `RateLimitError` (`code: 'RATE_LIMITED_COOLDOWN'`, `notSent: true`, `retryAfter` = remaining seconds; no credentials appear in the message) until the deadline passes. Duration is `Retry-After` clamped to `cooldown.maxMs` (default 15 minutes), or `cooldown.defaultMs` (default 60 s) when the header is missing or unparseable; the thread-threshold 429 (vendor code `Itgenatr005`, which clears in seconds) arms only a short window: min(`Retry-After`, 10 s), default 5 s. A 2xx from a call that got through clears the gate early; expiry is lazy (the first call after the deadline deletes the entry and proceeds); a hostile `Retry-After` (huge, zero, negative, non-numeric) is clamped to the cap or falls back to the default, so a lock-out is always bounded. `thresholdInformation.get()` bypasses the gate so a throttled tenant can still read its own headroom (a 2xx from it clears the gate; a 429 re-arms it). The state is one in-process map shared by every client instance (expired entries are swept when a new cooldown is armed); `cooldown: { enabled: false }` disables the gate. Gated calls emit an audit event marked `notSent` and do not count toward `rateLimit.perHour`. Limits worth knowing: the gate is per process — other processes and integrations on the same tenant are invisible to it (`thresholdInformation` is the only cross-integration signal); it can mask early recovery if the vendor window clears before `Retry-After`; a process that loads both the ESM and the CJS build gets two maps (deliberately not anchored on `globalThis`).
98
97
 
99
98
  Credential-shaped fields are redacted in audit payloads by default.
100
99
  Every 2xx JSON body also passes through the response redaction transform
@@ -145,17 +144,11 @@ live-verified on `company_notes.create`): it drives the transport's no-retry dec
145
144
  and the `errorFromStatus` override that marks a matching 500 permanent
146
145
  (`retryable: false`, `details.deterministic: true`, a field-catalog
147
146
  `suggestedAction`) while keeping the `ServerError` class + `SERVER_ERROR`
148
- code + status 500 (issue #23 — the shared signal for the #26 breaker
149
- predicate). A third house class is thrown by the transport's pre-send gate
150
- (issue #26): `CircuitBreakerError` (`CIRCUIT_BREAKER_OPEN`, policy,
151
- `notSent: true` — the call was refused locally and never sent;
152
- `retryable: true` with `retryAfter` = the remaining open seconds (absent
153
- for the half-open probe-wait refusal) — a DELIBERATE divergence from #6's
154
- `RATE_LIMITED_COOLDOWN` refusal (`retryable: false`): the open period is
155
- bounded and advertised via `retryAfter`, so retrying the SAME call after
156
- the wait is the correct caller behaviour; `details = { state: 'open' |
157
- 'half-open', retryAfterMs, failureCount }` rides the error and the MCP
158
- served payload verbatim).
147
+ code + status 500 (issue #23). A third house class is thrown by the transport's pre-send gate
148
+ (issue #285): `MaintenanceError` (`MAINTENANCE`, server, `notSent: true` — the zone's
149
+ maintenance gate is open and the call was refused locally and never sent;
150
+ `retryable: true` with `retryAfter` = seconds to the next probe slot;
151
+ `details = { since, retryAfterMs }` — section 3.2.2).
159
152
 
160
153
  ### 1.6 `pagination.ts` — page walk
161
154
 
@@ -261,7 +254,7 @@ status → what the vendor does that triggers it.
261
254
  | `ServerError` | `SERVER_ERROR` | server | 5xx | vendor 5xx — retryable — EXCEPT a deterministic field-validation 500 (a known phrase, `isDeterministicVendor500`): same class/code/status, `retryable: false` + `details.deterministic: true` (issue #23) |
262
255
  | `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). A non-JSON `2xx` maintenance page is waited out up to `maintenanceMaxWaitMs` (default 1 h) for reads AND writes; at `4xx`/`5xx` the page is waited out only for calls safe to re-send (GET / explicitly idempotent-flagged) — an unflagged mutation makes one send and carries `details.maintenanceDetected` (issue #114); a wait that ran is reported on the surfaced error as `details.maintenance` (section 2.1) |
263
256
  | `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) |
264
- | `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) |
257
+ | `MaintenanceError` | `MAINTENANCE` | server | — (refused, not sent) or the page's status | the zone is in its maintenance window (issue #285): either the gate refused the call LOCALLY (zero upstream calls, no hourly stamp, `notSent: true`) or the call itself received the maintenance page and did not wait it out (`httpStatus` set, `details.maintenanceDetected`); `retryAfter`, `details.since` — **retryable: true**, except an unflagged write answered at a non-2xx status (outcome unknown; section 3.2.2) |
265
258
  | `ResolutionError` | `RESOLUTION_AMBIGUOUS` / `RESOLUTION_TRUNCATED` / `NOT_FOUND` | resolution | — | `resolve`: multiple exact matches (candidates in `resourceIds`, labeled in `details.idRefs`); a cap-tripped bounded client scan (`resolution.scanOnMiss` only; never a silent `null`); a numeric-id miss |
266
259
  | `PaginationLimitError` | `PAGINATION_LIMIT` | policy | — | a page walk that would exceed the 1000-page guard (`suggestedAction` names the tighter filter) |
267
260
  | `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 |
@@ -281,12 +274,9 @@ body fails identically on retry — issue #23). `UNEXPECTED_CONTENT_TYPE` and
281
274
  `EMPTY_RESPONSE` are retryable (advisory — the transport retries a non-JSON 2xx
282
275
  per policy on idempotent methods only, and never retries a blank 2xx).
283
276
  `RATE_LIMITED_COOLDOWN` is not retryable before its deadline — it is a local
284
- gate and the call was never sent. `CIRCUIT_BREAKER_OPEN` is retryable with
285
- `notSent: true` + `retryAfter` = the remaining open seconds — the host
286
- circuit breaker's local refusal (issue #26): the deliberate divergence from
287
- `RATE_LIMITED_COOLDOWN`'s not-retryable — the open period is bounded and
288
- advertised, so retrying the SAME call after the wait is the correct caller
289
- behaviour (service health, not request shape — no narrowing needed).
277
+ gate and the call was never sent. `MAINTENANCE` is retryable with
278
+ `notSent: true` + `retryAfter` — the zone's maintenance gate refused the call
279
+ locally (issue #285); retry the SAME call after the window.
290
280
  `suggestedAction` is deterministic next-step text only (never free prose
291
281
  from the vendor).
292
282
 
@@ -324,7 +314,7 @@ transport reads the header:
324
314
 
325
315
  | `Content-Type` on the response | outcome |
326
316
  |---|---|
327
- | non-JSON-ish (`text/*` — incl. `text/json` — or anything not `application/json*` / a plus-json suffix) | `UNEXPECTED_CONTENT_TYPE` (server, retryable — advisory; `details = { httpStatus, contentType, bodySnippet }`) |
317
+ | non-JSON-ish (`text/*` — incl. `text/json` — or anything not `application/json*` / a plus-json suffix) | `UNEXPECTED_CONTENT_TYPE` (server, retryable — advisory; `details = { httpStatus, contentType, bodySnippet }`); a body that reads as the maintenance page is `MAINTENANCE` instead (section 3.2.2) |
328
318
  | JSON-ish (`application/json*`, a plus-json suffix) | `INVALID_RESPONSE` (status quo — the #13/W-A7 shape-drift key; a wrong-shape 2xx is a shape-level failure, not a transport one) |
329
319
  | ABSENT | `INVALID_RESPONSE` (status quo — conservative; the maintenance-page premise carries a header) |
330
320
 
@@ -357,8 +347,8 @@ notice:
357
347
  - a non-JSON **`4xx`/`5xx`** page is waited out and retried only for calls that
358
348
  are **safe to re-send** (`retriable5xx`: a `GET` or an explicitly
359
349
  `idempotent`-flagged call). An UNFLAGGED mutation answered with such a page
360
- makes **exactly one send** and surfaces the status's own error with
361
- `details.maintenanceDetected = true` and a verify-before-reissuing action — a
350
+ makes **exactly one send** and surfaces `MaintenanceError` (`retryable:
351
+ false`) with `details.maintenanceDetected = true` and a verify-before-reissuing action — a
362
352
  proxy can serve that page AFTER forwarding the write, so the outcome is
363
353
  unknown (the §2.2 write rule is intact).
364
354
 
@@ -368,7 +358,9 @@ observed ceiling is 4 h (`MAINTENANCE_WINDOW_MAX_MS`; 19 live windows:
368
358
  30–240 min, median ≈ 2 h) — an unattended worker should pass
369
359
  `maintenanceMaxWaitMs: MAINTENANCE_WINDOW_MAX_MS`. The wait does not consume
370
360
  `maxRetries`, each post-wait attempt gets a fresh `timeoutMs`, and a caller
371
- `AbortSignal` interrupts the wait. A non-JSON HTML-ish page that does not look
361
+ `AbortSignal` interrupts the wait. Only one call per zone waits at a time: the
362
+ first page opens the zone's maintenance gate and every other call fails fast
363
+ with `MAINTENANCE` (section 3.2.2). A non-JSON HTML-ish page that does not look
372
364
  like a maintenance notice (for example a wrong `baseUrl` serving a web site, or
373
365
  a stock proxy page) is surfaced at once with
374
366
  `details.maintenancePatternMiss = true` and the 256-byte snippet logged at
@@ -448,7 +440,7 @@ carries `notSent: true` is row 1, not row 3):
448
440
 
449
441
  | # | condition | state | retryable | why |
450
442
  |---|-----------|-------|-----------|-----|
451
- | 1 | `notSent === true` (the cooldown gate's / circuit breaker's local refusal) | `failed` | the error's own flag | the request never reached the vendor; the breaker's refusal retries the same call after the wait, the cooldown's is `retryable: false` (wait, then narrow the query first) |
443
+ | 1 | `notSent === true` (the cooldown gate's / maintenance gate's local refusal) | `failed` | the error's own flag | the request never reached the vendor; the maintenance refusal retries the same call after the window, the cooldown's is `retryable: false` (wait, then narrow the query first) |
452
444
  | 2 | `httpStatus` in 400..499 except 429 | `failed` | `false` | the vendor answered; the write is definitely not applied |
453
445
  | 3 | `httpStatus === 429` | `failed` | `true` | a pre-processing refusal — retry once the cooldown / Retry-After allows |
454
446
  | 4 | `httpStatus === 500` and `parseVendorErrors(vendorError)` returns a non-empty array | `failed` | `false` | a deterministic business-rule rejection — the same body fails identically on retry |
@@ -513,21 +505,18 @@ Host form: `https://<name>.autotask.net/atservicesrest` (lowercase
513
505
  | `timeoutMs` | 30 000 | — | whole-call budget, incl. retries |
514
506
  | `maxRetries` | 3 | — | the client's retry budget, overridable per call (`InvokeOptions.maxRetries` / `MutationOpts.maxRetries`; `0` = exactly one send per request the call performs). The budget caps three legs (issues #105/#109): the 429 leg (retried on GET/PUT/DELETE + flagged calls — unchanged) and the 5xx/network + non-JSON 2xx legs (GET + flagged only) |
515
507
  | `maintenanceMaxWaitMs` | 3 600 000 (1 h) | — | **maintenance-window auto-retry** (weekly Autotask window): when the first 256 bytes of a non-JSON body read as a maintenance notice, the transport waits `maintenancePollMs` and retries for up to this long from the FIRST such page. A `2xx` page is waited out for reads AND writes (edge-served before execution); a `4xx`/`5xx` page only for calls safe to re-send (GET / explicitly idempotent-flagged) — an unflagged mutation makes ONE send and carries `details.maintenanceDetected` (a proxy can serve the page after forwarding). JSON bodies never enter. Does not consume `maxRetries`; each post-wait attempt gets a fresh `timeoutMs`. `0` disables the wait. A non-matching HTML-ish page stamps `details.maintenancePatternMiss`. Observed ceiling 4 h: `MAINTENANCE_WINDOW_MAX_MS` (19 windows: 30–240 min, median ≈ 2 h) |
516
- | `maintenancePollMs` | 30 000 | — | poll interval while waiting out a maintenance page |
508
+ | `maintenancePollMs` | 30 000 | — | poll interval while waiting out a maintenance page, and the maintenance gate's probe interval (section 3.2.2) |
517
509
  | `maxConcurrent` | 10 | 10 (vendor) | concurrency guard |
518
510
  | `profile` | `library` | — | `library` = today's defaults (changes nothing); `agent` = the conservative `AGENT_PROFILE` limits for LLM-driven workloads (below). Resolution: explicit config > profile > library default |
519
511
  | `maxResponseBytes` | 67 108 864 (64 MiB) | — | largest response body read; above it the call throws `RESPONSE_TOO_LARGE` (non-retryable) |
520
512
  | `rateLimit.perHour` | off | 10 000 (vendor) | opt-in client-side hourly limiter (sliding window; waits within the call deadline, else `RateLimitError`). `null` is the explicit "disabled" value (beats the profile). The limiter is **per process** — it cannot see other instances or integrations on the tenant (`thresholdInformation.get()` is the tenant-wide readout; `client.hourlyBudget()` is the local one) |
521
- | `cooldown` | on | 15 min (`cooldown.maxMs`) | per-tenant 429 cooldown gate (issue #6) — after a surfaced 429, further calls for that tenant (lowercased username + host) fail locally (`RATE_LIMITED_COOLDOWN`, `notSent: true`) until the deadline; `enabled: false` disables; `defaultMs` is the missing-`Retry-After` window (60 s); `store` injects a custom `CooldownStore` |
522
- | `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 |
513
+ | `cooldown` | on | 15 min (`cooldown.maxMs`) | per-tenant 429 cooldown gate (issue #6) — after a surfaced 429, further calls for that tenant (lowercased username + host) fail locally (`RATE_LIMITED_COOLDOWN`, `notSent: true`) until the deadline; `enabled: false` disables; `defaultMs` is the missing-`Retry-After` window (60 s) |
523
514
  | `resolution.maxScanRecords` | 500 | — | bounded client-scan cap (section 7, `resolve`) |
524
515
  | `resolution.maxScanPages` | 4 | — | bounded client-scan cap |
525
516
  | `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) |
526
517
  | `logger` | `NoopLogger` | — | request log sink |
527
518
  | `redactPersonalData` | `true` | — | response redaction for PII + confidential fields (section 10); `false` = raw personal data, secrets still redacted |
528
- | `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) |
529
- | `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 |
530
- | `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) |
519
+ | `cache` | on | read 4096 / field 256 entries | `{ ttlMs?, fieldTtlMs?, store? }` the SDK's in-memory caches (issue #286; one block): single-record reads (get-by-id, the id branch of `resolve`, composite FK reads, `operations.expandResourceNames` names) live `ttlMs` (default 5 min; the reference tables `REFERENCE_ENTITIES` live 1 h), entity field metadata lives `fieldTtlMs` (default 5 min). `false` = every channel off. A real write through the client clears the record cache; a vendor drift signal drops one entity's field metadata (section 11.14). Lists, queries, counts, mutations and errors are never cached. `fieldCache`, `resourceNameCache` and `cache.perResourceTtlMs` were removed in 0.40.0 (the first two are refused) |
531
520
  | `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 |
532
521
  | `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 }` |
533
522
  | `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) |
@@ -590,88 +579,41 @@ shell-quoting/line-break artifacts — check your env load (…)`) or was clean
590
579
  likely cause`). The message and `suggestedAction` are unchanged — the
591
580
  diagnostic is appended as a separate field, and a 401 is still never retried.
592
581
 
593
- #### 3.2.2 The per-host circuit breaker (issue #26)
594
-
595
- `circuitBreaker: { enabled: true }` opts into the optional circuit breaker
596
- at the transport (OFF by default — without it, behaviour is byte-identical
597
- to before; no env overrides — `src/config/` never reads `process.env`).
598
- Vendor health is HOST-scoped (a zone outage hits every tenant on that
599
- zone), so the key is the lowercased host of the resolved base URL
600
- (`buildBreakerKey` — the same derivation as `buildTenantKey`'s host part,
601
- without the username). Consequences:
602
-
603
- - **A different host is unaffected**; a different tenant on the SAME host
604
- (different credentials — including a per-tenant pooled client on a
605
- shared-host gateway) SHARES the circuit: one tenant's outage opens the
606
- breaker for that host's other tenants too. That is the documented
607
- consequence of the host-only key; `enabled` is off by default, so nothing
608
- changes unless opted in.
609
- - The breaker is PER PROCESS (like the #6 gate and the hourly tracker): in
610
- an N-process gateway there are N independent breakers per host — during a
611
- half-open window at a downed host, up to N × `halfOpenProbes` probes go
612
- upstream (one admission set per process); the state is not shared across
613
- processes. Keep the process count small when the vendor is maintenance-
614
- prone, or size `halfOpenProbes` accordingly.
615
- - The state rides the STORE, not the client: the default store is the
616
- in-process in-memory map shared by every client instance (capped at 1024
617
- entries, evicting the oldest last-activity first — failing open;
618
- deliberately not `globalThis`-anchored — the #6 two-builds note applies),
619
- so a pool LRU eviction of a tenant client loses nothing (an
620
- evicted-then-recreated tenant on the same host hits the same state).
621
- `store` injects a custom `CircuitBreakerStore` (the same get/set/delete
622
- shape as `CooldownStore`, over the richer per-key state
623
- `{ failures, openedAt, successes, probes, windowOpenedAt? }`;
624
- `windowOpenedAt` is the open-window identity a probe's settlement is
625
- pinned to — persist it as-is, and keep every process sharing a store on
626
- the same version, since a build that re-arms an entry without it leaves
627
- newer pinned probes voided with their slot unreleased).
628
-
629
- **States (timer-free — derived from `Date.now()` lazily at each request,
630
- exactly like the #6 cooldown):** `closed` (no entry / nothing armed — a
631
- healthy host leaves no store entry) → at `failureThreshold` counted
632
- failures strictly inside the sliding `failureWindowMs` window, the circuit
633
- OPENS (`openedAt` recorded) → while open, calls are refused LOCALLY in the
634
- same pre-send slot as the #6 cooldown gate (OUTSIDE the retry loop, before
635
- the hourly limiter, the semaphore and fetch: zero fetches, no hourly
636
- stamp, the audit event carries `notSent: true` + `errorCode:
637
- 'CIRCUIT_BREAKER_OPEN'`) → after `openTimeoutMs` the state is half-open
638
- (the lazy transition): up to `halfOpenProbes` probes go upstream (further
639
- calls while the probe budget is in flight are refused with
640
- `details.state: 'half-open'` and no defined `retryAfter` — there is no
641
- defined wait; a probe verdict clears the refusal); a probe SUCCESS counts
642
- toward `successesToClose` (reached → closed, counters reset, the entry
643
- deleted); a probe FAILURE re-arms the open period from now. A counted
644
- failure is the SURFACED outcome (after the transport's own retries) —
645
- retries are the transport's business; the breaker counts the call's end.
646
-
647
- **The default failure predicate** (injectable via
648
- `circuitBreaker.isFailure`; the default `isBreakerFailure` counts exactly):
649
- `NETWORK_ERROR`, `TIMEOUT`, a vendor 429 (keyed on the STATUS — a surfaced
650
- 429 may carry an adopted vendor machine code, e.g. the thread-threshold
651
- `Itgenatr005`), a 5xx EXCEPT the deterministic field-validation 500 (keyed
652
- on #23's `details.deterministic` signal — by that signal only), and
653
- `UNEXPECTED_CONTENT_TYPE` (the #24 maintenance page, by code — NEVER
654
- `INVALID_RESPONSE`: the W-A7 shape-drift and the #13 marker-recovery key
655
- are untouched). Never counted: `ABORTED` (a caller's cancellation says
656
- nothing about service health), any 4xx, and local/policy refusals
657
- (`notSent` — the cooldown gate's or the breaker's own; a call that never
658
- reached the vendor is never a service failure).
659
-
660
- **Validation (fail-closed at construction, the #6 pattern):** every number
661
- must be a positive integer; `halfOpenProbes` MUST be `>= successesToClose`
662
- (a breaker that can never close is a misconfiguration — refused); the
663
- store must implement `get`/`set`/`delete`; `isFailure` must be a function.
664
- Validation runs even with `enabled: false`.
665
-
666
- **The threshold bypass (the #6 precedent):** `thresholdInformation.get()`
667
- bypasses the REFUSAL so a downed host can still be probed for headroom —
668
- its outcome is still RECORDED: a 2xx counts toward `successesToClose`
669
- (closes toward recovery); a counted failure re-arms the open period. An
670
- already-aborted caller signal still wins over an open-circuit refusal (the
671
- abort error, not the breaker error).
672
-
673
- Surfacing breaker state in the doctor is a FOLLOW-UP (not requested by the
674
- issue); the state is inspectable through the injected/default store.
582
+ #### 3.2.2 The per-zone maintenance gate (issue #285)
583
+
584
+ Autotask takes each zone down for a weekly maintenance window; the edge then
585
+ serves an HTML maintenance page (recognised at any status when the 256-byte
586
+ head of a non-JSON body contains `maintenance`, `outage`, `downtime` or
587
+ `down for`; a JSON body never is). Generic 503 wording (`service
588
+ unavailable`, `temporarily unavailable`) is NOT the signature: such a page
589
+ keeps the ordinary 5xx retry leg and never opens the gate. The gate is process-wide, keyed by the
590
+ lowercased host of the live base URL, and always on:
591
+
592
+ - The first call that sees the page OPENS the zone's gate (`since` = now).
593
+ - While open, every other call on the zone is refused LOCALLY in the pre-send
594
+ slot (after the #6 cooldown check, before the hourly limiter, the semaphore
595
+ and fetch: zero fetches, no hourly stamp, the audit event carries
596
+ `notSent: true` + `errorCode: 'MAINTENANCE'`) with `MaintenanceError`.
597
+ - One call per `maintenancePollMs` is admitted as the PROBE (single-flight: a
598
+ call that is waiting the window out owns the probe slot for its whole wait).
599
+ The first non-maintenance response from the zone, from any call, CLOSES it.
600
+ - The call that saw the page (or the admitted probe) waits the window out up
601
+ to `maintenanceMaxWaitMs`, polling every `maintenancePollMs`, when it is
602
+ safe to re-send (section 2.1). Every call that receives the page and does
603
+ not wait it out (wait off, budget spent, another call owns the probe slot,
604
+ or an unflagged write at a non-2xx status) throws the same
605
+ `MaintenanceError`, with `httpStatus` set, no `notSent`, and
606
+ `details.maintenanceDetected` + the 256-byte `bodySnippet`; the unflagged
607
+ non-2xx write is `retryable: false` with a verify-first action (its outcome
608
+ is unknown). So a consumer maps one code: `MAINTENANCE`.
609
+ `maintenanceMaxWaitMs: 0` disables the wait (fail fast); the gate still opens. A maintenance page never takes
610
+ the generic retry leg.
611
+ - A non-matching HTML page (a wrong `baseUrl` serving a web site) never opens
612
+ the gate; it surfaces with `details.maintenancePatternMiss`.
613
+ - Only the page signature is detected: a window that answers with refused or
614
+ reset connections surfaces as `NETWORK_ERROR` and does not open the gate.
615
+ - The state is per process (like the #6 gate and the hourly tracker); an
616
+ N-process gateway sends up to N probes per interval.
675
617
 
676
618
  ### 3.3 `onAudit(event)`
677
619
 
@@ -680,8 +622,8 @@ One event per completed request (never called before the request settles):
680
622
  errorCode?, notSent? }` — `body` is the request body with credential-shaped
681
623
  fields redacted by default; `status` is absent when the request failed before
682
624
  a response; `notSent: true` marks a call that was refused locally by the
683
- tenant cooldown gate or the circuit breaker (section 3.2.2 — disambiguate on
684
- `errorCode`: `RATE_LIMITED_COOLDOWN` vs `CIRCUIT_BREAKER_OPEN`); in either
625
+ tenant cooldown gate or the zone maintenance gate (section 3.2.2 — disambiguate on
626
+ `errorCode`: `RATE_LIMITED_COOLDOWN` vs `MAINTENANCE`); in either
685
627
  case nothing reached the wire.
686
628
 
687
629
  ## 4. Pagination contract
@@ -777,33 +719,9 @@ carry paging parameters.
777
719
  `limit >= latest` (above that the returned count is governed by
778
720
  `latest`; `limit` is the blind-branch walk window). `latest` is
779
721
  query-verb-ONLY (`queryAll` / `count` bags are unchanged).
780
- **The count-probe cache (issue #127).** The walk's count probes (the
781
- initial `count(F)` + every `id gt T` threshold probe) are cached per
782
- client in a short-TTL in-process store: 30 s TTL, 4096-entry memory
783
- guard, keyed by (resource plan key, canonical filter serialisation).
784
- A re-asked "top N" within 30 s makes ZERO extra vendor count calls for
785
- every CACHED probe (full-set AND threshold mode); a probe that answered
786
- 0 is deliberately re-wired (a 0 is never cached — see below), so a
787
- threshold search that hit a 0 pays those probes again. A real write
788
- through the SAME client
789
- clears the cache — and a probe still in flight across the write is
790
- DISCARDED by the cache's invalidation epoch (issue #139: its pre-write
791
- answer must not re-anchor a stale top-N within the TTL — the next
792
- `latest` re-probes). Never cached: the blind-surface / pinned-empty
793
- sentinels (the plan-pinned stamps stay fresh and re-derive on every
794
- call), errors, and anything outside the probe path — the plain `count`
795
- op and `operations.aggregate` call the vendor count directly and
796
- promise fresh totals. A write made ELSEWHERE (another client, the
797
- Autotask UI) shows up once the 30 s TTL expires — that is the
798
- documented snapshot trade. The truncation guard covers the
799
- growth-of-total direction; a stale-LOW total within the TTL is NOT
800
- left to it: when a full-set walk truncates against a cached total,
801
- the SDK re-probes the total ONCE fresh (invalidating the entry) and
802
- continues the walk to a larger fresh total — the
803
- `LATEST_WINDOW_INCOMPLETE` refusal stands only when the fresh probe
804
- confirms the cached value (bounded: at most one extra count probe per
805
- walk). A total of `0` is NEVER cached, so a repeated call after an
806
- out-of-band insert re-probes instead of returning a false empty.
722
+ **Count probes are never cached** (issue #286 removed the issue #127
723
+ count cache): every `latest` call pays its own count probes, so a
724
+ repeated "top N" sees the vendor's current totals.
807
725
  - **`noteChars: N` (issue #61) — the opt-in per-field free-text cap.**
808
726
  `N` (integer >= 1, pre-request validated, zero wire) caps EVERY
809
727
  string value in the resource's untrusted-class free-text fields
@@ -975,16 +893,15 @@ types, type-only) alongside `./capabilities` and `./operations`.
975
893
 
976
894
  ### Pooled clients and the shared read cache (`./pool` subpath, issue #219 N6)
977
895
 
978
- `createClientPool({ maxSize?, idleTtlMs?, factory?, sharedReadCache? })` keeps
896
+ `createClientPool({ maxSize?, sharedReadCache? })` keeps
979
897
  one `AutotaskClient` per credential tuple (`{ get, delete, clear, size }`) so a
980
898
  multi-tenant gateway reuses each tenant's limiter state. Each pooled client has
981
899
  its own read cache, so a write through one client never cleared what another
982
900
  client of the SAME tenant would serve — a post-write read-back routed to a
983
- re-created client (a `delete(config)` on a 401, an LRU eviction, an idle
984
- expiry) could return the pre-write snapshot for up to the resource TTL
901
+ re-created client (a `delete(config)` on a 401 or an LRU eviction) could return the pre-write snapshot for up to the resource TTL
985
902
  (contacts: 5 min).
986
903
 
987
- **Default (shared):** the pool OWNS one `ReadCache` per credential tuple and
904
+ **Default (shared):** the pool OWNS one record `TtlCache` per credential tuple and
988
905
  hands it to every client it builds for that tuple (via the `cache.store`
989
906
  config seam), so any write through any pooled client of that tenant voids the
990
907
  read cache for all of them and a read-back is never served the pre-write
@@ -993,8 +910,8 @@ entries still expire on their own TTL), is pruned at `maxSize` and dropped by
993
910
  `clear()`. `sharedReadCache: false` restores per-client caches (then route a
994
911
  post-write read-back through the writing client). `cache: false` opts the tuple
995
912
  out entirely. `cache.store` is otherwise a plain advanced seam: a supplied
996
- `ReadCache` instance is used as-is, and `ttlMs` / `perResourceTtlMs` beside it
997
- refuse (the instance owns its TTLs).
913
+ `TtlCache` instance is used as-is, and `ttlMs` beside it refuses (the instance
914
+ owns its TTL).
998
915
 
999
916
  ### Fresh single-record reads (`fresh: true`, issue #250)
1000
917
 
@@ -1155,7 +1072,7 @@ be cross-mode-refused.
1155
1072
  whose MCP annotation is `readOnlyHint !== true` — today exactly **267
1156
1073
  tools**: the 265 dedicated write/delete tools (209 `effect=write` + 56
1157
1074
  `effect=destructive`, every one of them has a dedicated tool) +
1158
- `autotask_write` + `autotask_delete`. The remaining **635 tools** (the 622 dedicated read tools + the 10 core read tools + `autotask_read` +
1075
+ `autotask_write` + `autotask_delete`. The remaining **634 tools** (the 621 dedicated read tools + the 10 core read tools + `autotask_read` +
1159
1076
  `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
1160
1077
  and are safe ungated: every op they can route is `effect=read` (zero
1161
1078
  mutating requests — pinned by the test inventory, §6 of the split design).
@@ -1299,7 +1216,7 @@ the authority read (section 11.2).
1299
1216
  one wire verb is `GET /V1.0/Resources/{id}`, an id of any other vendor
1300
1217
  entity (a ticket / company / contact / billing-code id) resolves to an
1301
1218
  unrelated person's name when it collides with a live staff id (and the
1302
- wrong name then sticks in the default-on `resourceNameCache` under that id
1219
+ wrong name then sticks in the default-on read cache under that id
1303
1220
  until TTL — a one-off mis-feed keeps serving it; section 11.21), or to a
1304
1221
  misleading `blind: true` marker when it does not. An `AutotaskError`'s
1305
1222
  legacy `resourceIds` may carry exactly those (the field is unlabeled —
@@ -1335,17 +1252,14 @@ the unlabeled field.
1335
1252
  resolved, blind, failed }`.
1336
1253
  - **Wire cost**: `N` unique ids cold = `N` GETs; warm (inside the TTL of
1337
1254
  the channel below) = 0.
1338
- - **Cache**: the `resourceNameCache` channel (section 11.21) —
1339
- each distinct id is fetched at most once per cache lifetime (the blind
1340
- marker is cached too: a repeat miss costs no wire; a failure is never
1341
- cached). Default-on (issue #80) — with `false` the op is the uncached per-id
1342
- path (per-call de-duplication still applies). A `resources` write
1343
- through the same client clears the channel; a rename made elsewhere
1344
- shows up once `ttlMs` expires (the documented staleness contract). A
1345
- `fresh: true` call bypasses BOTH the name cache AND the ReadCache (the
1346
- per-id `resources.get` fetches live and refreshes the ReadCache entry as
1347
- well) — a rename made elsewhere is served on the very next fresh call,
1348
- never a stale snapshot (issue #77).
1255
+ - **Cache**: the record cache (section 11.21) — each distinct id is
1256
+ fetched at most once per cache lifetime (the not-found marker is cached
1257
+ too: a repeat miss costs no wire; a failure is never cached). With
1258
+ `cache: false` the op is the uncached per-id path (per-call
1259
+ de-duplication still applies). Any write through the same client clears
1260
+ it; a rename made elsewhere shows up once `ttlMs` expires. A
1261
+ `fresh: true` call bypasses and refreshes the cache — a rename made
1262
+ elsewhere is served on the very next fresh call (issue #77).
1349
1263
 
1350
1264
  ```ts
1351
1265
  import { expandResourceNames } from 'node-autotask/operations';
@@ -1488,9 +1402,10 @@ notes cannot be deleted, so they never use create-then-delete.
1488
1402
  optionally scoped to one company (a `companyID eq` leaf and-joined server-side with the title match) and
1489
1403
  projected to the listed columns (`id` is always returned). `searchWithDetail` takes the same bag. Malformed
1490
1404
  `companyID` / `includeFields` are refused before any request. Other resources' `search` is unchanged.
1491
- - **Deprecated (issue #278, since 0.38.0; removed in the next minor):** `time_entries.recentTickets` is a
1492
- strict subset of an aggregate grouped by ticket now that relation filters (§5) and default labels (§11.27)
1493
- exist. Replacement (1-2 calls instead of 2+):
1405
+ - `search` refuses an unknown option key (a misspelt `companyId` can't silently widen the search), and drops an
1406
+ empty `userDefinedFields` the vendor appends to projected rows.
1407
+ - **Hours per ticket for a resource** (`time_entries.recentTickets` was removed in 0.41.0, issue #290): aggregate
1408
+ the time entries grouped by ticket, using relation filters (§5) and default labels (§11.27):
1494
1409
 
1495
1410
  ```ts
1496
1411
  const res = await aggregate(client, 'time_entries.query', [
@@ -1505,22 +1420,9 @@ notes cannot be deleted, so they never use create-then-delete.
1505
1420
 
1506
1421
  For per-ticket status / assignee / company use `operations.fetchByIds` on the group ticket ids (labels
1507
1422
  included: `statusLabel`, `assignedResourceIDName`, `companyIDName`).
1508
- - `time_entries.recentTickets(from, to, { resourceID, companyID?, tz?, limit? })` — the distinct tickets a
1509
- resource logged time against in an inclusive `dateWorked` window (`YYYY-MM-DD`; UTC days, or the local days in
1510
- `tz`). One bounded time-entry read (`dateWorked` + `resourceID`; `limit` 1-500, default 500, bounds the
1511
- **entries** scanned) plus `id in [...]` ticket reads (100 ids each). Result `{ items, entriesRead,
1512
- entriesWithoutTicket, unresolvedTicketIDs, window, truncated }`; each item carries `ticketID`, `ticketNumber`,
1513
- `title`, `status`, `assignedResourceID`, `companyID`, `entryCount`, `totalHours`, `lastWorked`, newest first.
1514
- TimeEntry has no `companyID`, so `companyID` is applied **after the join**, client-side on the ticket rows'
1515
- own `companyID` (the ticket read is not company-filtered on the wire). Entries on other companies' tickets
1516
- still consume `limit`; those tickets are only counted in `excludedTickets` (ids are not returned), and
1517
- `unresolvedTicketIDs` holds only tickets the read did not return at all (deleted or invisible, dropped from
1518
- `items` when `companyID` is set). `truncated: true` (same meaning as `postedWithin`) means the rollup covers
1519
- the scanned entries only; narrow the window. `search` refuses an unknown option key (a misspelt `companyId`
1520
- can't silently widen the search), and drops an empty `userDefinedFields` the vendor appends to projected rows.
1521
- - **`tz` on the `dateWorked` ops** (`recentTickets`, `postedWithin`, `dateWorkedWindow`, `customRange` and the relative
1423
+ - **`tz` on the `dateWorked` ops** (`postedWithin`, `dateWorkedWindow`, `customRange` and the relative
1522
1424
  windows): the vendor stores `dateWorked` as a date-only value at 00:00 UTC, so the wire bounds are always whole UTC days.
1523
- For `recentTickets` / `postedWithin` / `dateWorkedWindow` / `customRange` the `from`/`to` days ARE the days, so `tz` is
1425
+ For `postedWithin` / `dateWorkedWindow` / `customRange` the `from`/`to` days ARE the days, so `tz` is
1524
1426
  validated but never shifts them. For the relative windows (`today`, `last7`, `lastFullMonth`, ...) `tz` picks which
1525
1427
  calendar dates the window covers in that zone and the bounds are whole UTC days of those dates. (Before 0.24.0 a `tz`
1526
1428
  behind UTC, e.g. `America/Los_Angeles`, selected the NEXT UTC day; live-verified 2026-10-23.)
@@ -1585,8 +1487,7 @@ resolver holds a status literal: the move engine's closed-target refusal,
1585
1487
  `tickets.forResource(resourceID, opts?)` lists the tickets assigned to one
1586
1488
  resource, as primary assignee, secondary resource, or both (the `scope` option,
1587
1489
  default `both`; the seam is `Tickets.assignedResourceID` for primary and the
1588
- `TicketSecondaryResources` link table for secondary). It follows
1589
- `time_entries.recentTickets`: two bounded reads (a `Tickets/query` on
1490
+ `TicketSecondaryResources` link table for secondary). Two bounded reads (a `Tickets/query` on
1590
1491
  `assignedResourceID` and a `TicketSecondaryResources/query` on `resourceID`,
1591
1492
  both `id in [...]`-chunked when the link set is large) unioned client-side —
1592
1493
  deliberately not the cross-field grouped `or`, whose live semantics are
@@ -1814,7 +1715,7 @@ const lob = await revenueRollup(client, { tenantWide: true, dateFrom: '2025-07-0
1814
1715
  // lob.rows[i].keys.lineOfBusiness → { id: 7, name: '<level2>', division: '<level1>' } | { id: null, label: 'no LOB assigned' }
1815
1716
  ```
1816
1717
 
1817
- ## 8. Resource surface (135 resources)
1718
+ ## 8. Resource surface
1818
1719
 
1819
1720
  <!-- BEGIN GENERATED:api-per-entity -->
1820
1721
  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).
@@ -1829,7 +1730,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1829
1730
  | `checklist_libraries` | ChecklistLibrary | entity | extended | no | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibrary_get |
1830
1731
  | `checklist_library_checklist_items` | ChecklistLibraryChecklistItem | entity | extended | yes (checklist_libraries) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibraryChecklistItem_get |
1831
1732
  | `classification_icons` | ClassificationIcon | entity | advanced | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_classificationIcon_get |
1832
- | `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, searchByDomain, searchByIdentity, unbilledWork, upsertByName | autotask_company_get |
1733
+ | `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, prepareCrmWrite, searchByDomain, searchByIdentity, unbilledWork, upsertByName | autotask_company_get |
1833
1734
  | `company_alerts` | CompanyAlert | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyAlert_get |
1834
1735
  | `company_locations` | CompanyLocation | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyLocation_get |
1835
1736
  | `company_notes` | CompanyNote | entity | extended | yes (companies) | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyNote_get |
@@ -1870,8 +1771,9 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1870
1771
  | `contract_service_units` | ContractServiceUnit | entity | extended | yes (contracts) | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractServiceUnit_get |
1871
1772
  | `contract_services` | ContractService | entity | extended | yes (contracts) | query, count, get, create, update, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractService_get |
1872
1773
  | `contract_ticket_purchases` | ContractTicketPurchases | entity | extended | yes (contracts) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractTicketPurchase_get |
1873
- | `contracts` | Contract | entity | extended | no | query, count, get, create, update, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo, profitability | autotask_contract_get |
1774
+ | `contracts` | Contract | entity | extended | no | query, count, get, create, update, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo, lineOfBusiness, profitability | autotask_contract_get |
1874
1775
  | `countries` | Country | entity | advanced | no | query, count, get, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_country_get |
1776
+ | `currencies` | Currency | entity | advanced | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_currency_get |
1875
1777
  | `departments` | Department | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_department_get |
1876
1778
  | `domain_registrars` | DomainRegistrar | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_DomainRegistrar_get |
1877
1779
  | `expense_item_attachments` | ExpenseItemAttachment | entity | extended | yes (expense_items) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_expenseItemAttachment_get |
@@ -1935,7 +1837,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1935
1837
  | `ticket_categories` | TicketCategory | entity | extended | no | query, count, get, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketCategories_get |
1936
1838
  | `ticket_category_field_defaults` | TicketCategoryFieldDefault | entity | extended | yes (ticket_categories) | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketCategoryFieldDefaults_get |
1937
1839
  | `ticket_change_request_approvals` | TicketChangeRequestApproval | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketChangeRequestApprovals_get |
1938
- | `ticket_charges` | TicketCharge | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketCharge_get |
1840
+ | `ticket_charges` | TicketCharge | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo, moveToTicket | autotask_ticketCharge_get |
1939
1841
  | `ticket_checklist_items` | TicketChecklistItem | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketChecklistItem_get |
1940
1842
  | `ticket_checklist_libraries` | TicketChecklistLibrary | entity | extended | yes (tickets) | create, resolve | attachToDocument, getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketChecklistLibrary |
1941
1843
  | `ticket_history` | TicketHistory | entity | extended | no | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketHistory_get |
@@ -1944,8 +1846,8 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1944
1846
  | `ticket_notes` | TicketNote | entity | extended | yes (tickets) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketNote_get |
1945
1847
  | `ticket_secondary_resources` | TicketSecondaryResource | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketSecondaryResources_get |
1946
1848
  | `ticket_webhooks` | TicketWebhook | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketWebhook_get |
1947
- | `tickets` | Ticket | entity | core | no | query, count, get, create, update, resolve, search, getContext | changeStatus, findByTicketNumber, forResource, fullContext, getEntityInfo, getFieldInfo, getUdfInfo, openWithNoteAndAssign, slaHealthCheck, statusSemantics, summary, timeline | autotask_ticket_get |
1948
- | `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | approve, createForResource, createIfNotExists, customRange, dateWorkedWindow, getEntityInfo, getFieldInfo, getPosted, getUdfInfo, getUnposted, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, postedWithin, today, yesterday | autotask_timeEntry_get |
1849
+ | `tickets` | Ticket | entity | core | no | query, count, get, create, update, resolve, search, getContext | changeStatus, findByTicketNumber, forResource, fullContext, getEntityInfo, getFieldInfo, getUdfInfo, lineOfBusiness, openWithNoteAndAssign, slaHealthCheck, statusSemantics, summary, timeline | autotask_ticket_get |
1850
+ | `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | approve, createForResource, createIfNotExists, customRange, dateWorkedWindow, getEntityInfo, getFieldInfo, getPosted, getUdfInfo, getUnposted, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, moveToTicket, postedWithin, today, yesterday | autotask_timeEntry_get |
1949
1851
  | `time_entry_attachments` | TimeEntryAttachment | entity | extended | yes (time_entries) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_timeEntryAttachment_get |
1950
1852
  | `time_off_requests` | TimeOffRequest | entity | extended | no | query, count, get, resolve | approve, getEntityInfo, getFieldInfo, getUdfInfo, reject | autotask_timeOffRequest_get |
1951
1853
  | `inventory_products` | InventoryProduct | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo, stockedItemsAdd, stockedItemsRemove, stockedItemsTransfer | autotask_inventoryProduct_get |
@@ -1957,7 +1859,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1957
1859
  | `threshold_information` | — | integration | core | no | get | — | autotask_threshold_information |
1958
1860
  | `zone_information` | — | integration | advanced | no | get | — | autotask_zone_information |
1959
1861
 
1960
- 129 resources (122 entity + 7 integration). Rows are generated from the registry — do not hand-type.
1862
+ 128 resources (121 entity + 7 integration). Rows are generated from the registry — do not hand-type.
1961
1863
  <!-- END GENERATED:api-per-entity -->
1962
1864
 
1963
1865
  **`companies.search` include_family (issue #59).** `companies.search` is
@@ -2890,10 +2792,10 @@ reality*), so the point-in-time pins (`operations.createGuidance`, the
2890
2792
  `zoneFacts` lists) are only as fresh as their `verifiedAt`. The live
2891
2793
  channel is the refresh side of that pair:
2892
2794
 
2893
- **Config (on by default; `false` opts out):** `fieldCache: { ttlMs?, maxEntities? }` — a
2894
- separate block from `cache` (different key — entity vs resource+id —,
2895
- different lifetime, different invalidation signal: drift vs write). With
2896
- it `false` there is no `FieldCache` instance at all and the
2795
+ **Config (on by default; `cache: false` opts out):** `cache: { fieldTtlMs? }` — a
2796
+ separate `TtlCache` instance from the record cache (different key — entity vs
2797
+ resource+id —, different lifetime, different invalidation signal: drift vs
2798
+ write). With `cache: false` there is no field cache at all and the
2897
2799
  `getEntityInfo` / `getFieldInfo` / `getUdfInfo` trio is the uncached wire
2898
2800
  path, bit-for-bit; absent, the default instance is built and the trio
2899
2801
  goes through one private helper
@@ -3039,52 +2941,18 @@ closed bag, the W-A4 sentence in the served purpose, and the
3039
2941
  `udfCapable` / `getUdfInfo` / `getFieldInfo` row invariants);
3040
2942
  `test/scripts/g16-field-catalog.test.ts` is the tripwire.
3041
2943
 
3042
- ### 11.15 Per-resource read-cache TTLs (issue #32)
3043
-
3044
- One `ttlMs` forces the operator to choose the worst case for the two very
3045
- different reads an agent session mixes: reference tables that change on a
3046
- human timescale (countries, departments, roles, billing codes, services)
3047
- and records that change minute to minute (tickets, time entries). The
3048
- override makes the choice per resource:
3049
-
3050
- **Config (the default map; `false` opts out):** `cache: { ttlMs?,
3051
- perResourceTtlMs?: Record<string, number> } | false` — every value is a
3052
- positive integer, validated fail-closed in `resolveConfig` exactly like
3053
- `ttlMs` (wrong type / non-integer / non-positive → `AutotaskConfigError`
3054
- at construction). Issue #80: the plan-derived table
3055
- `DEFAULT_PER_RESOURCE_TTL_MS` IS the default per-resource map — with the
3056
- operator map absent, every plan resource rides its table row (the 1h
3057
- reference class / the 5min default class) and a plan-unnamed path expires
3058
- on `ttlMs`; operator entries OVERRIDE table entries per resource (the
3059
- effective map is operator entry > table entry > `ttlMs`).
3060
-
3061
- **The key contract (pinned):** the entry's TTL is the override for the
3062
- **plan key of the entity the read path points to** — the cache key is
3063
- still the wire path (`/V1.0/<Entity>/<id>`, unchanged), and the base read
3064
- path keys the TTL by the entity of that path. A cross-entity read
3065
- therefore expires on the READ entity's override: a contact read through
3066
- `tickets.getContext` is stored under the contact's key (the plan-derived
3067
- path index `RESOURCE_KEY_BY_PATH`), so a `contacts`/`resources` override
3068
- bounds it, not the ticket's. A path the plan does not name falls back to
3069
- `ttlMs` (the entry, not the call, decides the expiry).
3070
-
3071
- **The plan-derived default table (not hand-written):** the plan carries
3072
- no staleness marker (the closed `kind` set is entity/integration), so the
3073
- reference SET is a pinned design decision in `scripts/lib/cache-ttl.mjs`
3074
- — the issue's own motivation names the human-timescale lookup tables
3075
- (countries, departments, roles, billing_codes, services) and the prior
3076
- art (the independent WYRE-AI `calculateResponseTTL`) adds phases. The
3077
- table STRUCTURE is plan-derived: `src/cache-ttl-tables.ts` (the 7th
3078
- generated artifact, planHash-stamped, regenerated by
3079
- `generate-capabilities`) carries one row per plan resource — reference
3080
- class 1h (`CACHE_TTL_REFERENCE_MS`) vs default class 5min
3081
- (`CACHE_TTL_DEFAULT_MS`) — so a plan change regenerates the split, and a
3082
- hand-written table would be the one drift hazard. It is a REFERENCE for
3083
- building the operator's map (spread + per-operator pins, e.g.
3084
- `{ ...DEFAULT_PER_RESOURCE_TTL_MS, tickets: 60_000 }`), never applied
3085
- implicitly. MCP: nothing new served; the config is operator-side.
3086
- Gate: `check-capabilities` clause 8a + `test/scripts/cache-ttl-artifact.test.ts`
3087
- (byte-equal re-derivation — the table regenerates with the plan).
2944
+ ### 11.15 Read-cache TTLs (issue #32, simplified in #286)
2945
+
2946
+ Reference tables change on a human timescale; records change minute to
2947
+ minute. The record cache therefore keys its TTL on the entity segment of the
2948
+ cached read path (`readTtlFor` in `src/cache.ts`): the tables in
2949
+ `REFERENCE_ENTITIES` (BillingCodes, Countries, Departments, Phases, Roles,
2950
+ Services) live `REFERENCE_TTL_MS` (1 h, or `ttlMs` when that is longer);
2951
+ every other record lives `cache.ttlMs` (default 5 min). The key is the wire
2952
+ path, so a cross-entity read (a contact read through `tickets.getContext`)
2953
+ expires on the READ entity's TTL, and a not-found marker on its path's TTL.
2954
+ The per-resource override map (`cache.perResourceTtlMs`) and the generated
2955
+ per-resource table were removed in 0.40.0.
3088
2956
 
3089
2957
  ### 11.16 The opt-in PUT item-path fallback for a 404'd update route (issue #34)
3090
2958
 
@@ -3208,9 +3076,7 @@ config surface, no new ops) and pins the two hazards the issue names:
3208
3076
  - **The FK parts are cached; the ticket root stays fresh.** The
3209
3077
  reference-table FK parts ride the normal cached get-by-id path (the #32
3210
3078
  keying: each part is stored under the read entity's path and expires on
3211
- that entity's per-resource TTL — the plan-derived 5min non-reference
3212
- class via `DEFAULT_PER_RESOURCE_TTL_MS`, or the operator's
3213
- `perResourceTtlMs` pin). The ticket read itself is FRESH (`fetchRecordRaw`
3079
+ that entity's TTL (section 11.15). The ticket read itself is FRESH (`fetchRecordRaw`
3214
3080
  with `fresh: true`): the composite root is always the live record —
3215
3081
  caching is scoped to the reference-table FK parts only (a repeat
3216
3082
  `getContext` wires exactly the ticket; a repeat `fullContext` wires the
@@ -3219,7 +3085,7 @@ config surface, no new ops) and pins the two hazards the issue names:
3219
3085
  the current (cacheable) root behaviour.
3220
3086
  - **Negative cache, no poisoning.** A vendor NOT-FOUND on an FK part (the
3221
3087
  404 or the 200 `item: null` signal) is stored as an 'absent' marker in
3222
- the SAME `ReadCache` under a namespaced key the positive path never
3088
+ the SAME record cache under a namespaced key the positive path never
3223
3089
  constructs (the `.get` / `resolve` single-record read paths are bit-
3224
3090
  for-bit untouched): a repeat composite serves 'absent' with zero wire
3225
3091
  for that part. The marker expires on the read entity's TTL and dies
@@ -3250,7 +3116,7 @@ config surface, no new ops) and pins the two hazards the issue names:
3250
3116
 
3251
3117
  - **Opt-in names/labels ride these same channels (issue #77).** The
3252
3118
  composites' opt-in `names` siblings resolve over the existing
3253
- default-on channels (the name cache / the ReadCache / the fieldCache —
3119
+ default-on channels (the record cache / the field cache —
3254
3120
  no new cache) with a separate name-read budget (`maxNameReads`,
3255
3121
  default 20, lowerable never raised) — see section 11.21 for the
3256
3122
  per-field-class wiring.
@@ -3305,21 +3171,12 @@ what makes it usable as a recovery signal from inside the client.
3305
3171
  transport's size cap): today's typed 401 propagates
3306
3172
  byte-identically — zero retry (the recovery must not turn a credential
3307
3173
  fault into a discovery fault).
3308
- - **State hygiene.** The transport's per-host state (the tenant rate key
3309
- and the circuit-breaker key, both keyed on the base URL) is RE-DERIVED
3310
- on the re-point, so the re-pointed host starts FRESH — no cooldown /
3311
- breaker / tenant-rate inheritance from the stale host (conservative: the
3312
- stale host's armed entries stay in the shared stores for the other
3313
- clients that still talk to it; nothing is copied across). A circuit-
3314
- breaker PROBE admitted on the stale host is pinned to that host's key:
3315
- if the call re-points, the probe is voided (its outcome was measured on
3316
- a different host) — the re-pointed host's probe budget / close count is
3317
- never touched by a stale-host probe, and the stale host's admitted
3318
- budget dangles for the rest of the open period (bounded, conservative,
3319
- nothing crosses hosts). The probe is equally pinned to the open WINDOW
3320
- it was admitted in (issue #271): a probe that settles after that window
3321
- closed and re-opened is voided the same way — the newer window's probe
3322
- budget / close count is never touched by a stale-window probe. The
3174
+ - **State hygiene.** The transport's per-host gate keys (the tenant
3175
+ cooldown key and the maintenance-gate key, both keyed on the base URL) are
3176
+ RE-DERIVED on the re-point, so the re-pointed host starts FRESH — no
3177
+ cooldown / maintenance-gate inheritance from the stale host (its armed
3178
+ entries stay for the other clients that still talk to it; nothing is copied
3179
+ across). The
3323
3180
  re-point is
3324
3181
  recorded on the client: the live base URL is observable via
3325
3182
  `http.baseUrl` (the configured `config.baseUrl` stays the
@@ -3341,9 +3198,8 @@ what makes it usable as a recovery signal from inside the client.
3341
3198
  lifetime, like a cache); no change to the served MCP/n8n text (transport
3342
3199
  behaviour, like #34).
3343
3200
 
3344
- Pins: `test/transport-zone-reresolve/*.test.ts` (11 pins, mock wire only —
3345
- no vendor wire in tests; the 10 issue-required + the breaker probe
3346
- host-pinning pin).
3201
+ Pins: `test/transport-zone-reresolve/*.test.ts` (mock wire only — no vendor
3202
+ wire in tests).
3347
3203
 
3348
3204
  ### 11.20 Config-time host bounds — `hostPolicy` (skill 3.5.0 / L2, audit-fix A1)
3349
3205
 
@@ -3416,74 +3272,19 @@ Pins: `test/config-host-policy.test.ts` (10 pins — the exported helper is
3416
3272
  CONSUMED by the tests, never re-implemented; mock wire only, no vendor
3417
3273
  wire).
3418
3274
 
3419
- ### 11.21 Resource name expansion and the name cache (issue #65, default-on since #80)
3275
+ ### 11.21 Resource name expansion (issue #65; names in the read cache since #286)
3420
3276
 
3421
3277
  `operations.expandResourceNames` (section 7.7) resolves vendor numeric
3422
- resource ids to display names with PER-ID `resources.get` reads ONLY.
3423
- Its channel is `resourceNameCache: { ttlMs?, maxEntries? }` — ON by
3424
- default (issue #80: absent builds the default instance, 1 h / 1024
3425
- entries); `resourceNameCache: false` is the uncached per-id path,
3426
- bit-for-bit (per-call de-duplication is unconditional).
3427
-
3428
- **The channel** (`src/resourceNameCache.ts` — a THIRD cache channel
3429
- beside `cache` and `fieldCache`; a separate channel, NOT shared with
3430
- either):
3431
-
3432
- - **Key space**: `resources:<id>` (the canonical id). Disjoint from the
3433
- ReadCache (wire-PATH keys) and the FieldCache (`resource:kind` keys —
3434
- the closed three-kind metadata set). The namespace leaves a documented
3435
- path to future FK tables — v1 serves `resources` only.
3436
- - **What is stored**: exactly the two deterministic vendor answers — the
3437
- resolved name `{ name, blind: false }` and the vendor's TRUE not-found
3438
- blind marker `{ name: null, blind: true }` (the issue-#36
3439
- absent-marker precedent: a repeat miss costs no wire). A 200 WITH a
3440
- record but NO name part is NOT stored (a transient partial record
3441
- must not stick for the whole TTL). A FAILURE is never cached
3442
- (a fault rejects before any `set` can run).
3443
- - **In-flight de-duplication**: concurrent callers for the same id share
3444
- ONE wire request (the promise is dropped on settle, success OR
3445
- failure — the fieldCache two-armed pattern).
3446
- - **Cap / TTL**: `ttlMs` an optional positive integer, default 1 h (fail-closed
3447
- `AutotaskConfigError`); `maxEntries` default 1024
3448
- (`RESOURCE_NAME_CACHE_MAX_ENTRIES`); past the cap → purge expired
3449
- first, then evict the oldest-inserted (lazy purge on read).
3450
- - **Scoped invalidation**: a real write to the `resources` resource
3451
- through the same client (a rename via this client) clears the channel;
3452
- a write to ANY OTHER resource never invalidates (it says nothing about
3453
- names — the fieldCache "an ordinary record write never invalidates"
3454
- rule, scoped variant). The existing ReadCache all-write clear is
3455
- UNCHANGED.
3456
- - **Coexistence with the ReadCache** (when BOTH are configured): the
3457
- op's per-id `get` rides the ReadCache transparently (a ReadCache hit
3458
- inside its TTL costs zero wire and the name is then stored in the name
3459
- cache — double layering, disjoint key spaces). A `resources` write
3460
- clears BOTH; a write to another resource clears only the ReadCache. A
3461
- `fresh: true` call bypasses BOTH channels and refreshes both (the
3462
- per-id GET fetches live — issue #77: a rename made elsewhere is served
3463
- on the very next fresh call, never a stale snapshot).
3464
- - **Zero interaction with the #64 field-cache surface**: `FIELD_CACHE_KINDS`
3465
- stays the closed `['info','fields','udf']`; the fieldCatalog bag, its
3466
- honesty wording and the `changeStatus` live guard and its
3467
- `getFieldInfo` path are bit-for-bit untouched. The only shared code is
3468
- the `http.request` completion hook, which gains ONE
3469
- scoped-invalidation line (the fieldCache drift block and the readCache
3470
- all-write clear are unmodified).
3471
- - **NO new row in `src/cache-ttl-tables.ts`** (stated so the absence is
3472
- not hunted): the artifact's row set is pinned at exactly one row per
3473
- PLAN RESOURCE, and issue #65 adds a plan OPERATION, not a plan
3474
- resource. The rows — including `resources: 300000`, the ReadCache
3475
- per-resource TTL REFERENCE for `resources.get` record reads — are
3476
- unchanged; the name channel's TTL is the operator's
3477
- `resourceNameCache.ttlMs`, not the table's row. Only the artifact's
3478
- `planHash` header line re-stamps (the plan bytes move).
3479
-
3480
- **Staleness contract**: a resource renamed OUTSIDE this client (the UI,
3481
- another integration) serves its old name until `ttlMs` expires (or a
3482
- local `resources` write clears the channel) — the exact staleness trade
3483
- every house cache documents ("a cached read is a snapshot AT FETCH
3484
- TIME"). A `fresh: true` call closes the gap immediately: it bypasses
3485
- BOTH the name cache and the ReadCache and refreshes both, so the rename
3486
- is served on the very next fresh call (issue #77).
3278
+ resource ids to display names with PER-ID `resources.get` reads ONLY. The
3279
+ names live in the record cache (section 11.15), not a channel of their own:
3280
+ a resolved id is the cached `/V1.0/Resources/{id}` record, and the vendor's
3281
+ true not-found is the same not-found marker the composite FK reads store
3282
+ (`contextAbsentKey`), so a repeat id (resolved OR blind) inside the TTL costs
3283
+ zero wire. A failure is never cached. Any real write through the client
3284
+ clears names with the rest of the record cache; a rename made elsewhere
3285
+ shows up once `cache.ttlMs` expires, or at once with `fresh: true` (the
3286
+ per-id GET fetches live and refreshes the entry, issue #77). `cache: false`
3287
+ is the uncached per-id path; per-call de-duplication is always on.
3487
3288
 
3488
3289
  **Per-part projection (issue #200)**: `companies.fullProfile` accepts
3489
3290
  `fields?: { locations?, contacts?, notes?, projects?, openTickets? }` — each a
@@ -3505,10 +3306,9 @@ output is byte-identical) + `maxNameReads?: number` (default 20,
3505
3306
  lowerable, never raised). With `names: true`, the siblings ride the
3506
3307
  EXISTING default-on channels — no new cache: resource ids (creator /
3507
3308
  owner / lead / the time-entry resource) resolve through
3508
- `operations.expandResourceNames` (name cache → ReadCache → one wire read
3309
+ `operations.expandResourceNames` (read cache → one wire read
3509
3310
  per distinct uncached id); record-FK names (role / billing code /
3510
- contract) ride the per-resource single-record gets (ReadCache, the
3511
- per-resource TTLs, the #36 absent-marker contract untouched); the CLOSED
3311
+ contract) ride the per-resource single-record gets (the record cache, the #36 absent-marker contract untouched); the CLOSED
3512
3312
  picklist scope (owner-approved list: the ticket fields
3513
3313
  `status` / `priority` / `ticketType` / `issueType` / `subIssueType` /
3514
3314
  `source` / `queue` ONLY — project / company picklists stay label-less)
@@ -3528,7 +3328,7 @@ rows and the reason rows.
3528
3328
 
3529
3329
  **AC mapping**: AC1 (opt-in; the explicit-`false` output unchanged) —
3530
3330
  the op is invoked only on request and the channel is default-on (issue
3531
- #80; `resourceNameCache: false` keeps the uncached per-id path
3331
+ #80; `cache: false` keeps the uncached per-id path
3532
3332
  bit-for-bit — the explicit-`false` pins are that proof). AC2 (each
3533
3333
  distinct id fetched at most once per cache lifetime) —
3534
3334
  per-call de-duplication is ALWAYS on (zero-config); with the channel
@@ -3541,10 +3341,8 @@ only wire verb is the per-id GET; the module imports no query builder
3541
3341
  (static guarantee) and the tests assert METHOD + PATH on every wire
3542
3342
  call.
3543
3343
 
3544
- Pins: `test/operations/expandResourceNames.test.ts` (11 pins — the plan
3545
- row's `tests[]`) + `test/resource-name-cache.test.ts` (7 pins — the
3546
- class unit + the client-option wiring + the scoped invalidation; mock
3547
- wire only, synthetic data, no vendor wire).
3344
+ Pins: `test/operations/expandResourceNames.test.ts` (the plan row's `tests[]`)
3345
+ and `test/resources/cache-write-invalidation.test.ts` (mock wire only).
3548
3346
 
3549
3347
  ### 11.22 Timezone-anchored date windows for billing items and invoices (issue #66)
3550
3348
 
@@ -4154,8 +3952,8 @@ keys of `operations.aggregate`, and the found records of `operations.fetchMany`
4154
3952
  invoke `<resource>.query` op when the summary matters.
4155
3953
  - Opt out per call: `names: false` (query bag key, aggregate opts, fetchMany /
4156
3954
  fetchByIds opts). A non-boolean `names` is refused pre-request.
4157
- - Caches: picklists ride the fieldCache, staff names the resource name cache,
4158
- record names the ReadCache (all default-on; invalidated as documented in §3 —
3955
+ - Caches: picklists ride the field cache, staff and record names the record
3956
+ cache (all default-on; invalidated as documented in §3 —
4159
3957
  a write through the client clears the affected resource's entries, TTL
4160
3958
  otherwise). A warm call costs zero extra vendor calls; a cold one costs one
4161
3959
  field-metadata GET for the resource plus one GET per distinct uncached id.