node-autotask 0.18.0 → 0.19.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.
- package/ARCHITECTURE.md +15 -8
- package/CHANGELOG.md +574 -0
- package/MCP_TOOL_CATALOG.json +2 -2
- package/MCP_TOOL_MANIFEST.md +5 -5
- package/N8N_MCP_MANIFEST.json +2 -2
- package/N8N_MCP_MANIFEST.md +3 -3
- package/README.md +8 -4
- package/capabilities.json +1223 -1223
- package/dist/capabilities.cjs +2 -2
- package/dist/capabilities.js +1 -1
- package/dist/{chunk-W2WSZOZA.js → chunk-4YKV7ORF.js} +2 -2
- package/dist/{chunk-7I3F3AJJ.cjs → chunk-7RDR5DSI.cjs} +393 -393
- package/dist/{chunk-HXOWEYV3.js → chunk-D5RAQ4DE.js} +2 -2
- package/dist/{chunk-5O5EPYBO.cjs → chunk-JM52ZU7T.cjs} +1222 -1222
- package/dist/{chunk-VYUZZIEE.js → chunk-LB2X4X4B.js} +86 -8
- package/dist/{chunk-VV2UUW2J.js → chunk-NDK7WYWA.js} +1222 -1222
- package/dist/{chunk-WRWX6WIK.cjs → chunk-QDYSHAU6.cjs} +147 -69
- package/dist/{chunk-G564CHXM.cjs → chunk-TIM75K3G.cjs} +135 -135
- package/dist/{index-BqPY04ZG.d.ts → index-DLugNSr7.d.ts} +27 -13
- package/dist/{index-D7x3MkLK.d.cts → index-aZ3tCh1h.d.cts} +27 -13
- package/dist/index.cjs +24 -22
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -4
- package/dist/operations/index.cjs +109 -109
- package/dist/operations/index.d.cts +2 -2
- package/dist/operations/index.d.ts +2 -2
- package/dist/operations/index.js +2 -2
- package/dist/pool.cjs +8 -8
- package/dist/pool.d.cts +1 -1
- package/dist/pool.d.ts +1 -1
- package/dist/pool.js +4 -4
- package/dist/resources/index.cjs +4 -4
- package/dist/resources/index.d.cts +1 -1
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/index.js +3 -3
- package/docs/API.md +48 -18
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.19.0] - 2026-10-19
|
|
11
|
+
|
|
12
|
+
**Maintenance-page detection at any status, pattern-miss capture, and two manifest/packaging corrections (issues #114, #115, #123).** The plan hash moves to `078f288c2800` (MINOR) — plan-pinned consumers must re-pin.
|
|
13
|
+
|
|
14
|
+
**Maintenance windows (#114).** The weekly-window auto-retry now recognises the edge page from the first 256 bytes of its body at any status (`2xx`, `4xx`, `5xx`), not only a non-JSON `2xx`: an HTML maintenance page served as `503` or `404` is waited out instead of burning the retry budget or surfacing `NOT_FOUND`. The gate stays the body — a JSON `503` keeps the normal rules (a write makes exactly one send). **Write safety is unchanged:** a non-JSON `2xx` page is waited out for reads AND writes (edge-served before execution), while a non-JSON `4xx`/`5xx` 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` plus a verify-before-reissuing action, because a proxy can serve that page after forwarding the write. A non-matching HTML-ish non-JSON page stamps `details.maintenancePatternMiss = true` and logs the 256-byte snippet at `debug`; a wait that ran is always reported (`details.maintenance`, `exhausted` true only when the budget was spent). `MAINTENANCE_WINDOW_MAX_MS` (4 h) is exported as the observed ceiling (19 live windows: 30–240 min, median ≈ 2 h); the default wait stays 1 h.
|
|
15
|
+
|
|
16
|
+
**Consumer-defaulted args (#115).** `contracts.profitability`'s window is now documented as consumer-defaultable: the SDK requires `dateFrom`/`dateTo`, and a manifest CONSUMER may fill them (iit-mcp-autotask uses the last full calendar month, UTC). The caveat rides the served plan text and the generated `inputSchema.opts.description`, pinned by `test/scripts/profitability-consumer-default.test.ts`.
|
|
17
|
+
|
|
18
|
+
**Packaging (#123).** `CHANGELOG.md` now ships in the npm tarball (`files[]`), so installed consumers get the release notes; `verify:pack` asserts it.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## [0.18.0] - 2026-10-19
|
|
22
|
+
|
|
23
|
+
**Four audit fixes (issues #118–#121): projected query walks, deterministic field-existence 500s, a pre-wire `includeFields` check, and projection honesty in `fieldCatalog`.** The plan hash moves to `5176d37fc9ba` — the served `metadata.usage` on every bounded `query` record described the OLD wire ("later pages POST the filter"), so the plan text is corrected with the behaviour. A consumer pinned to the plan must re-pin (this is a MINOR release).
|
|
24
|
+
|
|
25
|
+
**Pagination (#118).** Every page of a query walk now POSTs the SAME `QueryModel` body: `MaxRecords`, `IncludeFields` (with the `id` append) and the preserved `filter`. Before, page 2 onward POSTed `{ filter }` alone and the vendor returned the FULL model (the audit session reported 2500 rows walked with only the first 500 projected; live-reproduced here: page 1 of a three-field subset carried 4 keys, page 2 carried 76). The served `metadata.usage` for every bounded `query` record now states the same wire (and the same record's `metadata.args` now says `maxRecords` is a raw `queryAll`/`query` walk parameter that the bounded query ignores and the invoke bag refuses). Live-verified 2026-10-19 (ww29): page 1 carried 4 keys, page 2 returned all 76; a continuation POST carrying `IncludeFields` returns projected rows, while `MaxRecords` on a continuation is IGNORED (the vendor serves its own 500-row page — the walk slices to its limit, so results stay exact). `buildQueryBody` is the new shared builder (exported from the root; `buildQueryRequest` uses it too).
|
|
26
|
+
|
|
27
|
+
**Deterministic field-existence 500 (#119a).** `isDeterministicVendor500` gains the live-verified signature `Unable to find <field> in the <entity> Entity.` (an unknown `includeFields` name answers HTTP 500). Such a 500 now makes exactly ONE upstream call, carries `retryable: false` + `details.deterministic: true` and the field-catalog `suggestedAction`, and never counts toward the circuit breaker.
|
|
28
|
+
|
|
29
|
+
**Pre-wire `includeFields` name check (#119b).** The bounded `query` op validates every `includeFields` name BEFORE the query POST. The authority is the entity's live field metadata (`getFieldInfo()` through the cache-aware channel — warm cache = zero wire), degrading to the vendored spec (`src/field-sets.ts`) when that read faults; a live set wins when present, so a tenant-only field (`time_entries.postedDate`) passes and a vendored-only field the tenant lacks is refused. One typed `CONFIG_ERROR` names every unknown field (zero query wire). New query-bag key `fieldCheck: false` disables the name check (the `string[]` / non-empty shape check always runs; a non-boolean `fieldCheck` is refused pre-request, the `dryRun` precedent; an empty `includeFields: []` costs no metadata wire). Cost: one metadata GET per resource on a cold field cache, zero on a warm one.
|
|
30
|
+
|
|
31
|
+
**Tool-level refusal pointer (#120).** `unknownKeysRefusal` pointed at `autotask_describe`, which accepts only `resource.method` registry keys — a dead end on an MCP tool's top-level argument object. A refusal with NO registry operation now points at the MCP client's `list_tools` inputSchema; a refusal naming a registry operation (and every params-bag refusal) keeps the `autotask_describe` pointer.
|
|
32
|
+
|
|
33
|
+
**FieldCatalog projection marker (#121).** A `fields`-filtered `operations.fieldCatalog` row carries `projected: true`; the four derived lists (`required`, `readOnly`, `queryable`, `picklistFields`) then describe the projected set only. A full row omits the key, so a projected row can never be read as the resource's full capability picture. Additive only.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## [0.17.0] - 2026-10-18
|
|
37
|
+
|
|
38
|
+
**`AutotaskError.resourceIds` gets a labeled companion: `details.idRefs` (issue #116).**
|
|
39
|
+
`resourceIds` reads as Autotask **Resource** (staff) ids, but the special ops stamp ids of
|
|
40
|
+
many other entities into it — unlabeled — and the contract never said so: the failed
|
|
41
|
+
record's own id on a `get` miss of ANY entity (`http.unwrapItem`, the base resource
|
|
42
|
+
`get`/`fetchRecord`/scoped-delete), ticket ids on `tickets.openWithNoteAndAssign` /
|
|
43
|
+
`changeStatus`, the `companyID` on the create rejection, contact + company ids on
|
|
44
|
+
`contacts.moveToCompany` (stale-object and duplicate-email), a company id on
|
|
45
|
+
`contacts.upsertByEmail`, billing-code ids on the `time_entries` pinned-code failures,
|
|
46
|
+
staff ids on the assignee/owner prechecks. The hazard is concrete: the SDK's own
|
|
47
|
+
`operations.expandResourceNames` has exactly one wire verb — `GET /V1.0/Resources/{id}` —
|
|
48
|
+
so feeding it an error's `resourceIds` resolves a non-staff id that collides with a live
|
|
49
|
+
staff id to **an unrelated person's name**, and a non-colliding one to a misleading
|
|
50
|
+
`blind: true` marker. Every SDK stamp site now also carries
|
|
51
|
+
`details.idRefs: [{ entity, id }, ...]` — the same ids, each labeled with the vendor
|
|
52
|
+
entity name (singular snake_case: `company`, `ticket`, `contact`, `billing_code`,
|
|
53
|
+
`resource` (staff), … — the de-pluralized vendor table, the same word the error message
|
|
54
|
+
uses). Additive, per the #9/#14 `details` precedent: `resourceIds` is kept for
|
|
55
|
+
compatibility, `idRefs` is the machine-usable field. `expandResourceNames`' docs (here
|
|
56
|
+
and in `docs/API.md` §7.7) now say explicitly that it takes Resource (staff) ids only
|
|
57
|
+
and that error `resourceIds` may hold other entities. New exported type: `ErrorIdRef`
|
|
58
|
+
(`{ entity: string; id: number | string }`).
|
|
59
|
+
|
|
60
|
+
## [0.16.0] - 2026-10-18
|
|
61
|
+
|
|
62
|
+
**Maintenance-window auto-retry (the weekly Autotask window).** During the window the edge
|
|
63
|
+
answers an HTML page — a non-JSON `2xx`, `UNEXPECTED_CONTENT_TYPE` — before the vendor
|
|
64
|
+
application ever sees the request, so callers used to get a hard error (0.15.0) or a short
|
|
65
|
+
retry budget (≤ 0.14.x) in the middle of their work. The transport now waits the window out:
|
|
66
|
+
when the page reads as a maintenance notice it polls every `maintenancePollMs` (default 30 s)
|
|
67
|
+
and retries for up to `maintenanceMaxWaitMs` (default **1 hour**; a typical window is under
|
|
68
|
+
30 minutes), for **reads and writes**. The page is a pre-execution edge signal, which is the
|
|
69
|
+
one case where a bounded auto-retry of a write is safe — the general write rule is unchanged
|
|
70
|
+
(a `5xx`, network or timeout error still never auto-retries a write). The wait does not
|
|
71
|
+
consume `maxRetries`, each post-wait attempt gets a fresh `timeoutMs`, a caller `AbortSignal`
|
|
72
|
+
interrupts it, and a page that does not look like maintenance (a wrong `baseUrl`) surfaces
|
|
73
|
+
at once. On exhaustion the error carries
|
|
74
|
+
`details.maintenance = { waits, waitedMs, maxWaitMs, exhausted: true }`. Disable with
|
|
75
|
+
`maintenanceMaxWaitMs: 0` (config or per call).
|
|
76
|
+
|
|
77
|
+
## [0.15.0] - 2026-10-18
|
|
78
|
+
|
|
79
|
+
**The `#24` non-JSON-2xx leg now follows the write rule (issue #109).** A non-JSON `2xx` (an
|
|
80
|
+
edge-served maintenance page) kept the pre-#105 retriable set on this leg, so an unflagged
|
|
81
|
+
`PUT`/`DELETE` write could be auto-resent — even though `classifyWriteOutcome` pins
|
|
82
|
+
`UNEXPECTED_CONTENT_TYPE` on a write as `unknown` (never blindly resend). The transport now
|
|
83
|
+
applies the SAME predicate as the 5xx leg: a `GET` or an explicitly `idempotent`-flagged call
|
|
84
|
+
retries; any mutation makes ONE send and throws `UnexpectedContentTypeError` with the residue
|
|
85
|
+
`suggestedAction` (verify the record before re-issuing). The "the edge served the page" premise
|
|
86
|
+
is unprovable from the response, so the classifier invariant is now unconditional —
|
|
87
|
+
`unknown` ⇒ never auto-resend, on every leg. Explicit opt-ins stay: `idempotent: true` (a
|
|
88
|
+
transport `RequestOptions` flag) or a higher `maxRetries`.
|
|
89
|
+
|
|
90
|
+
## [0.14.0] - 2026-10-18
|
|
91
|
+
|
|
92
|
+
- **Fix (security containment): `wrapUntrusted` no longer passes pre-wrapped-LOOKING strings through (issue #103).**
|
|
93
|
+
The "already wrapped" shortcut decided by shape (starts with the open tag, ends with the close tag) and returned
|
|
94
|
+
the value UNCHANGED — vendor-supplied text could pre-wrap itself and skip the neutralisation of embedded wrapper
|
|
95
|
+
tags (prompt-injection containment hole for every `markUntrusted` / `deepMarkUntrusted` / `wrapUntrusted`
|
|
96
|
+
consumer). The shortcut is now the EXACT-WRAP test: the value must start with the open tag, end with the close
|
|
97
|
+
tag, AND contain neither tag in its interior. This module's own wrapped output always satisfies it (interior
|
|
98
|
+
tags are neutralised before wrapping), so wrapping stays idempotent for legitimately wrapped values; any value
|
|
99
|
+
with embedded tags — including the issue's hostile reproduction string — is neutralised and wrapped exactly
|
|
100
|
+
once. Public API unchanged: `wrapUntrusted(value: unknown): string | undefined` still returns plain strings
|
|
101
|
+
(the branded-wrapper-type alternative was considered and rejected — a String subclass would break
|
|
102
|
+
`typeof`/strict-equality/JSON consumers).
|
|
103
|
+
|
|
104
|
+
- **Added: `classifyWriteOutcome(error)` — the write-outcome classifier (issue #104).** New
|
|
105
|
+
`src/write-outcome.ts`, exported from the package root:
|
|
106
|
+
`classifyWriteOutcome(error: unknown): { state: 'failed' | 'unknown'; retryable: boolean; code: string;
|
|
107
|
+
httpStatus?: number }` answers, for a failed write, whether it `failed` (definitely not applied —
|
|
108
|
+
`notSent` local refusals, pre-send SDK-side refusals with no wire answer (validation / policy /
|
|
109
|
+
confirmation / stale-object / boundary, the client-side limiter), 4xx, 429, a 500 carrying a
|
|
110
|
+
parseable vendor `errors[]`) or is `unknown` (it may have been applied — timeouts, network errors,
|
|
111
|
+
caller aborts, blocked edge redirects, 5xx without a parseable `errors[]`, unreadable 2xx bodies). `unknown` is NEVER `retryable`. The
|
|
112
|
+
classifier's `retryable` is the WRITER's decision surface and is intentionally stricter than the
|
|
113
|
+
error's own `retryable` flag (which also serves read-path transport policy — a plain 5xx
|
|
114
|
+
`ServerError` keeps `retryable: true` for the transport). The Autotask vendor API has NO
|
|
115
|
+
idempotency-key support and no conditional-write header — every mutating operation is
|
|
116
|
+
non-idempotent on the vendor side, so an `unknown` outcome must be resolved by a READ-BACK (GET the
|
|
117
|
+
record, or query by a unique field), never by a blind resend. Docs: `docs/API.md` section 2.2;
|
|
118
|
+
tests: `test/write-outcome.test.ts`.
|
|
119
|
+
|
|
120
|
+
- **Behaviour change: mutations no longer auto-retry on 5xx or network errors (issue #105).** The
|
|
121
|
+
transport's retry policy now has two legs: a `429` (a pre-processing refusal — the vendor rejected
|
|
122
|
+
the call before executing it) is retried per `Retry-After` on the pre-#105 retriable set
|
|
123
|
+
(`GET`/`PUT`/`DELETE` + idempotent-flagged calls; an unflagged `POST`/`PATCH` 429 surfaces at
|
|
124
|
+
once, exactly as before #105) — that leg is UNCHANGED; on a `5xx` or network error only `GET` and
|
|
125
|
+
explicitly idempotent-flagged calls auto-retry. A mutation (`POST`/`PUT`/`PATCH`/`DELETE`) now
|
|
126
|
+
makes exactly one send on a `5xx`/network error — the write-outcome is unknown once the body was
|
|
127
|
+
written (issue #104's classifier: never blindly resend). The issue's observed fixture (an update
|
|
128
|
+
that saw 503 → 429 across 3 sends) makes one send on the 5xx leg. An explicit `idempotent: true`
|
|
129
|
+
(a transport-level `RequestOptions` flag, not exposed on the mutation helpers)
|
|
130
|
+
opts back into the pre-#105 auto-retry on both legs. The per-operation capability
|
|
131
|
+
registry follows: `retryableStatuses` is the full `[429, 500, 502, 503, 504]` for reads and
|
|
132
|
+
idempotent-flagged ops, `[429]` for `PUT`/`DELETE` mutations (global,
|
|
133
|
+
PUT-first wire) and for plain-PUT writes, `[]` for scoped (parent-collection,
|
|
134
|
+
PATCH-first) update rows — whose 404-gated put-fallback PUT send keeps the
|
|
135
|
+
pre-#105 429 leg — `[]` for `POST`/`PATCH` mutations, and `[]` for write
|
|
136
|
+
rows with no endpoint to classify against (the conservative default —
|
|
137
|
+
`resources.transferOwnership`'s PUT and `contacts.moveToCompany`'s DELETE
|
|
138
|
+
legs therefore under-claim the 429 leg). Census over the 1213 registry records:
|
|
139
|
+
961 read (full set) / 198 write / 54 destructive; the write rows split 162 `[]` /
|
|
140
|
+
35 `[429]` / 1 full-set write (`time_off_requests.approve`, a GET).
|
|
141
|
+
- **Added: per-call retry budget `maxRetries` (issue #105).** `InvokeOptions.maxRetries` and
|
|
142
|
+
`MutationOpts.maxRetries` (every mutation method, every special mutation op, and the governed
|
|
143
|
+
`invoke` path) override the client's configured `maxRetries` for the call's request and its
|
|
144
|
+
auxiliary reads (e.g. a partial-update merge read); the stale-guard pre-check keeps the client
|
|
145
|
+
budget. `0` = exactly one send per covered request, no auto-retry. This is how one client serves
|
|
146
|
+
both reads and writes: reads keep the full budget, a throttled write call is pinned to a single
|
|
147
|
+
send. (The `#24` non-JSON-2xx leg — a vendor maintenance page — keeps the pre-#105 retriable
|
|
148
|
+
set; unchanged in this release.)
|
|
149
|
+
|
|
150
|
+
## [0.13.0] - 2026-10-16
|
|
151
|
+
|
|
152
|
+
**`contracts.profitability` is summary-first, and its plan row is corrected.** The plan changed (the
|
|
153
|
+
`contracts.profitability` row), hence the minor bump; the new plan hash is
|
|
154
|
+
`bd88648453f58ede018062ce66acefacb672fecaa450af89f421057809971e14`. Consumers pinning the plan hash must re-pin.
|
|
155
|
+
|
|
156
|
+
- **Behaviour change: the default output no longer includes raw rows.** Each of the 16 per-part containers
|
|
157
|
+
(`contractServices` … `subscriptions`) now omits its `items` array by default and carries `rows_fetched` (the row
|
|
158
|
+
count) beside its unchanged stamps (`status`, `capped`, `hasMore`, `note`, `blindSurface`, `emptyReason`,
|
|
159
|
+
`absent`). Every computed figure is unchanged: the monthly P&L, `coverage_gaps`, `contract_type`,
|
|
160
|
+
`included_hours`, `currency`, the activity/costliest-ticket/miscoding/renewal folds, the data-quality warnings,
|
|
161
|
+
`costRateResolution`, `parts` and `wires`. On the 2-month test fixture the serialised result drops from 15,060
|
|
162
|
+
to 8,657 bytes (43%); the fixture rows carry only a handful of fields each, so on real vendor rows over a
|
|
163
|
+
12-month window the share taken by the rows, and so the saving, is much larger.
|
|
164
|
+
- **Added: `includeRows?: boolean`** (default `false`) in the opts bag. `true` returns the previous full shape,
|
|
165
|
+
rows included. A non-boolean value is refused pre-request with `CONFIG_ERROR`.
|
|
166
|
+
- **Signature change: `opts` is required.** `profitability(id, opts: ProfitabilityOptions)`: `dateFrom` and `dateTo`
|
|
167
|
+
live inside it and were always required. The registry and MCP argument map now mark `opts` required. A call
|
|
168
|
+
without it still fails pre-request with `CONFIG_ERROR`, now naming the missing bag and its `dateFrom`/`dateTo`.
|
|
169
|
+
- **Fix: `unposted_time.hours` is no longer a misleading 0.** When any of a month's unposted entries has no numeric
|
|
170
|
+
`hoursToBill`, `hours` is `null` with `hoursAbsent: 'HOURS_TO_BILL_MISSING'` instead of summing the missing values
|
|
171
|
+
as 0 (a deliberate `hoursToBill: 0` still counts as 0). On a Time & Materials contract `value` is then `null` with
|
|
172
|
+
`absent: 'HOURS_TO_BILL_MISSING'` rather than an under-priced figure. `hours` is now typed `number | null`.
|
|
173
|
+
- **Added: profitability types exported from the package root.** `ProfitabilityOptions`, `ProfitabilityPart`,
|
|
174
|
+
`ProfitabilityPartDigest`, `ContractsProfitabilityResult` and the result's nested row types are now importable
|
|
175
|
+
from `node-autotask` (type-only; the fold helpers stay internal). Narrow a part with `'items' in part`.
|
|
176
|
+
- **Fix: registry example.** The plan row carries an `exampleOverride` (a real call line and a synthetic-shape
|
|
177
|
+
note), replacing the refused `contracts.profitability('example-value')`.
|
|
178
|
+
- **Fix: wire budget text.** The plan text (`helperRationale`, `usage`, `args`) now says `<=21 bounded reads`,
|
|
179
|
+
matching `PROFITABILITY_WIRE_BUDGET`, instead of `<=18`.
|
|
180
|
+
- **Release bookkeeping:** `scripts/published-plan-hashes.json` now records 0.12.0 and 0.12.1 (both
|
|
181
|
+
`e4cf4228d5bad71cf762e31d4379ea9337633ededab68450b85f56bf3bc95fb9`).
|
|
182
|
+
|
|
183
|
+
## [0.12.1] - 2026-10-16
|
|
184
|
+
|
|
185
|
+
**`contracts.profitability` correctness fixes.** The plan is unchanged (plan hash
|
|
186
|
+
`e4cf4228d5bad71cf762e31d4379ea9337633ededab68450b85f56bf3bc95fb9`, same as 0.12.0), so consumers need no re-pin.
|
|
187
|
+
|
|
188
|
+
- **Fix: recurring cost is a cost.** Contract charges now cost their `extendedCost` (falling back to `unitCost ×
|
|
189
|
+
unitQuantity` when it is absent, or 0 while a unit cost exists). 0.12.0 summed `billableAmount`, the sell price.
|
|
190
|
+
- **Fix: included hours.** Service `units` are no longer read as hours. Included hours come from the
|
|
191
|
+
`ContractBlocks` `hours` vs `hoursApproved` on Block Hours contracts; any other contract type returns
|
|
192
|
+
`included_hours: { value: null, absent: 'NOT_BLOCK_HOURS_CONTRACT' }`.
|
|
193
|
+
- **Contract type by label, not id.** The op reads the Contracts field catalog and branches on the contractType
|
|
194
|
+
label (Block Hours, Time & Materials), never a hardcoded id. An
|
|
195
|
+
absent/0 type, an unlisted id or a catalog fault is `CONTRACT_TYPE_UNKNOWN`, never a silent "not Block Hours".
|
|
196
|
+
The raw id and label are in the new `contract_type` field. With this and the Subscriptions catalog read, the
|
|
197
|
+
wire budget is 21.
|
|
198
|
+
- **Fix: vendor subscriptions.** The CI join is now scoped to the CIs the contract covers
|
|
199
|
+
(`configurationItems.contractID`), not every CI the company has. `periodCost` is prorated by the tenant
|
|
200
|
+
`periodType` label from the Subscriptions field catalog; if that read fails, the verified id maps apply and the
|
|
201
|
+
note says so. A One-Time subscription is never prorated: its `periodCost` is booked whole in its `effectiveDate`
|
|
202
|
+
month. Only Active subscriptions count (Cancelled and Inactive are excluded); unrecognised period types are excluded
|
|
203
|
+
and named. The result is reported in `tooling_cost.vendor` and no longer folded into `total_cost`/`profit`/`margin`.
|
|
204
|
+
- **Fix: expected revenue.** It is now the period `price` of the service units starting in the month, compared only
|
|
205
|
+
against invoiced service lines (check 8 and `revenue_expected_unbilled`). Labour lines no longer mask a missed
|
|
206
|
+
recurring invoice.
|
|
207
|
+
- **Fix: service units windowed.** The units read is filtered to the window and re-queried per month on a live
|
|
208
|
+
cursor. A still-full month (any windowed part) is named in that month's new `coverage_gaps`.
|
|
209
|
+
- **Fix: tickets referenced by in-window time.** These are fetched in the activity read, so time on an older or
|
|
210
|
+
closed ticket is no longer flagged `ticket_not_in_fetched_set`. The expenses/probe join, the open items and the
|
|
211
|
+
stale check cover only tickets whose record names this contract.
|
|
212
|
+
- **Fix: `break_even_hours`** is now `(revenue − non-labour cost) / average resolved cost rate`, the labour hours
|
|
213
|
+
the revenue absorbs before profit reaches zero (0 when non-labour cost already exceeds revenue).
|
|
214
|
+
- **Fix: single-currency margin.** Revenue now uses `internalCurrencyExtendedPrice` and the units
|
|
215
|
+
`internalCurrencyPrice` (each falling back to the local field), matching the internal-currency costs; expenses fold
|
|
216
|
+
`internalCurrencyExpenseAmount`. Money fields are emitted rounded to cents.
|
|
217
|
+
- **Fix: revenue classification.** `billingItemType` drives the split (1/2 labour, 3 and 7 charges, 4 expenses, 6
|
|
218
|
+
recurring, 5 and 8 other). An absent or unknown type falls back to the line's own source FK (time, expense,
|
|
219
|
+
charge), with a service FK alone meaning recurring. Labour billed against a service is no longer classed
|
|
220
|
+
recurring.
|
|
221
|
+
- **Fix: lines on unreadable invoices** land in the new `revenue_unresolved_invoice` and the month's
|
|
222
|
+
`coverage_gaps` instead of vanishing.
|
|
223
|
+
- **Fix:** `revenue_expected` is `null` (unknown) when the service-units read faulted, not 0; `labour_hours` skips
|
|
224
|
+
zero/negative entries, as the cost does; `wires.requests` no longer counts a read-cache-served contract get.
|
|
225
|
+
- **Fix: degradation matches the docs.** A vendor fault on any bounded part now degrades that part to `status:
|
|
226
|
+
'absent'` with the typed `absent` code, and the affected months name it in `coverage_gaps`. A non-404 on the
|
|
227
|
+
customer read becomes `parts.customer: 'absent'` plus `customerAbsent`. Only the contract read is fatal.
|
|
228
|
+
- **Fix: window ends** are an exclusive next-day bound (`lt`), so datetime fields such as ticket `createDate` keep
|
|
229
|
+
the last day's later rows.
|
|
230
|
+
- **Added:** `revenue_by_type` (recurring/labour/charges/expenses/other, by the billing line's FK),
|
|
231
|
+
`revenue_posted_uninvoiced`, `unposted_time` (billable time with no billing item, priced at the contract role rate
|
|
232
|
+
on Time & Materials contracts), and `wires.requests` (raw HTTP count beside the logical `issued`).
|
|
233
|
+
- **Verified vendor facts** (read-only on the ww29 sandbox, 2026-10-16):
|
|
234
|
+
- contractType: 1 Time & Materials, 3 Fixed Price, 4 Block Hours, 6 Retainer, 7 Recurring Service, 8 Per Ticket,
|
|
235
|
+
9 Umbrella. contractCategory values are all inactive and are ignored.
|
|
236
|
+
- Subscriptions periodType: 1 One-Time, 2 Monthly, 3 Quarterly, 4 Semi-Annual, 5 Yearly. Subscriptions status: 1
|
|
237
|
+
Active, 2 Cancelled, 3 Inactive.
|
|
238
|
+
- billingItemType: 1 Labor, 2 Labor Adjustment, 3 Cost, 4 Expense, 5 Subscription, 6 Recurring Service/Bundle, 7
|
|
239
|
+
Recurring Contract Setup Fee, 8 Milestone. Labour lines billed against a service carry `contractServiceID`.
|
|
240
|
+
- Service-unit `price`/`cost` are period totals (units × per-unit, prorated for partial periods). The type-6
|
|
241
|
+
billing item has `itemDate` = the unit `startDate` and `extendedPrice` = the unit `price`.
|
|
242
|
+
- **By design:** a unit is attributed to the month its period starts, as it is billed, so monthly margins are a
|
|
243
|
+
billing-period (cash) view, not accrual. A unit spanning a year lands in one month. All windows and month
|
|
244
|
+
buckets are UTC days, so in a UTC+10/11 tenant a ticket created late local time can bucket one day earlier.
|
|
245
|
+
- **Known gaps (deferred to the next minor):** the registry example
|
|
246
|
+
`contracts.profitability('example-value')` needs a plan `exampleOverride`, and the plan text still says "<=18
|
|
247
|
+
bounded reads"; both change the plan hash, which a patch release must not do. The `opts` arg is still marked
|
|
248
|
+
optional: it comes from the shared positional-args map, and flipping it changes the MCP tool argument contract.
|
|
249
|
+
|
|
250
|
+
## [0.12.0] - 2026-10-16
|
|
251
|
+
|
|
252
|
+
**`contracts.profitability` lands (issue #100) — the contract P&L composite — and the ww29 zone facts are re-stamped
|
|
253
|
+
(issue #102).** The plan changed (the `contracts.profitability` plan row), hence the minor bump; the new plan hash is
|
|
254
|
+
recorded at publish.
|
|
255
|
+
|
|
256
|
+
- **`contracts.profitability` (issue #100).** The contract P&L composite: one call, 18 logical bounded reads (21 raw
|
|
257
|
+
HTTP), returning monthly revenue/cost/profit/margin/break-even, a per-row cost-rate ladder with a `costRateResolution`
|
|
258
|
+
source audit (a missing rate is a typed warning + an `unresolvedHours` lower bound, never a silent zero), 11 typed
|
|
259
|
+
capped data-quality checks, and per-part typed absent degradation (a part that cannot resolve degrades, never fails
|
|
260
|
+
the call). Plan changed -> new plan hash -> the iit-mcp-autotask consumer re-pins `BUILT_AGAINST_PLAN_HASH`
|
|
261
|
+
(consumer release follows).
|
|
262
|
+
- **WW29 zone facts re-stamped (issue #102).** After the 2026-10-15 re-probe: LIST-surface visibility-scoped wording,
|
|
263
|
+
get-by-id sees through the blind list (PROVEN 2026-10-15), and getPosted/getUnposted vendor-defect notes with the
|
|
264
|
+
live 500 body verbatim.
|
|
265
|
+
- **Fix:** the doctor-cli test env strips ambient `AUTOTASK_*` vars (the test could hit a live tenant over the network).
|
|
266
|
+
- **Tests:** the 18-wire hand-computed fixture + a 12-test degradation suite.
|
|
267
|
+
- **Surface:** the op's registry/plan/MCP-catalog rows + docs/API.md §11.26; updated machine-derived surface-count pins.
|
|
268
|
+
|
|
269
|
+
## [0.11.0] - 2026-10-16
|
|
270
|
+
|
|
271
|
+
**`operations.aggregate` gains date-bucket groupBy (issue #101), and the bucket surface is QA-hardened.** The plan
|
|
272
|
+
changed (the aggregate plan row + the new `MODEL_DATE_FIELDS` date-field sets), hence the minor bump; the new plan
|
|
273
|
+
hash is recorded at publish.
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
- **`operations.aggregate`: date-bucket groupBy (issue #101).** A `groupBy` field that is date-typed in the
|
|
277
|
+
vendored model may carry the closed bucket suffix `<field>@month` | `@week` | `@year`: the group key is named
|
|
278
|
+
by the full spec (e.g. `dateWorked@month`) and carries the ISO bucket label (`2026-09` / `2026-W38` / `2026`)
|
|
279
|
+
read on the UTC calendar — the same anchor the date-window ops use when `tz` is omitted (the API is UTC; a
|
|
280
|
+
date-only value is its UTC day, a zoned datetime its instant, a zoneless datetime a UTC wall time; weeks are
|
|
281
|
+
ISO 8601 week dates, Monday start, ISO year). The base validates against the vendored field set AND the
|
|
282
|
+
resource's date-typed fields (`MODEL_DATE_FIELDS` — the new generated map next to `MODEL_FIELD_SETS`) pre-request
|
|
283
|
+
(zero wire): an unknown suffix or a non-date base is the typed `CONFIG_ERROR` in the one-round complete problem
|
|
284
|
+
list. A null/absent value folds into the one null bucket group; a value that parses as no date keeps its raw
|
|
285
|
+
value as its own group. `sum`/`top`/`limit`/walk/truncation semantics are unchanged — the buckets ride the
|
|
286
|
+
existing client-side fold. Plan changed (the aggregate plan row + the new date-field sets), hence the new plan
|
|
287
|
+
hash — consumers re-pin with the bundled `contracts.profitability` release.
|
|
288
|
+
- **Tests:** `dateBucketLabel` label pins (month/year/ISO-week, the UTC anchor, the ISO-year edge weeks 2026-W01
|
|
289
|
+
and 2026-W53), the bucket fold (cross-month fixture, mixed raw+bucket, corrupt-value and null groups), the
|
|
290
|
+
bucket-suffix validation (non-date base, unknown suffix, unknown base, the complete problem list), and the
|
|
291
|
+
`MODEL_DATE_FIELDS` artifact pin.
|
|
292
|
+
- **Surface:** `AGGREGATE_DATE_BUCKETS` / `dateBucketLabel` (+ the `DateBucket` type) are re-exported from the
|
|
293
|
+
`./operations` subpath, so consumers can enumerate the closed bucket set (additive export).
|
|
294
|
+
- **Fix:** `dateBucketLabel` now buckets calendar years 0001-0099 correctly. `Date.UTC` maps years 0-99 to
|
|
295
|
+
1900+y, so such dates fell to the raw-group branch (two spellings of the same day split into two groups); the
|
|
296
|
+
year is now pinned before the round-trip check, with the same guard in the ISO-week-label computation (the ISO
|
|
297
|
+
year can dip one below the date's year — 0100-01-01 is 0099-W53).
|
|
298
|
+
|
|
299
|
+
## [0.10.0] - 2026-10-11
|
|
300
|
+
|
|
301
|
+
**`company_notes.create` is live-proven (DG-1), and the 0.9.0 review's loose ends are closed.** The plan changed (the
|
|
302
|
+
`company_notes` create guidance), hence the minor bump; the new plan hash is recorded at publish.
|
|
303
|
+
|
|
304
|
+
- **`operations.createGuidance` — `company_notes` is no longer `unproven`.** Verified live (sandbox, 2026-10-11): the create
|
|
305
|
+
needs `actionType`, `assignedResourceID`, `startDateTime` and `endDateTime` plus the parent `companyID` in the path
|
|
306
|
+
(vendor-demand order `endDateTime`, `actionType`, `startDateTime`, `assignedResourceID`). The vendor reports a missing
|
|
307
|
+
required field as **HTTP 500 `SERVER_ERROR`** with the field named (`Missing Required Field: actionType`), which is not a
|
|
308
|
+
transient fault: adding the field fixes it, retrying does not. The row moves from `unknown` to `delete-absent` (created
|
|
309
|
+
live; the vendor has no DELETE, cleanup is UI-only); catalog counts are now 12 `unknown` / 8 `delete-absent`.
|
|
310
|
+
- **`ClientPool` reports a cache-settings mismatch** (key order and `undefined` values never count as a difference). The pool key still ignores `cache` / `fieldCache` /
|
|
311
|
+
`resourceNameCache` (first-built client wins), but a later `get` that asks for different settings (e.g. `cache: false`) now
|
|
312
|
+
logs one warning per client through the caller's `config.logger` instead of being silently ignored; equivalent spellings
|
|
313
|
+
(absent, `{}`, `true`, `null`) never warn. `pool.delete(config)` then `get` rebuilds with new settings.
|
|
314
|
+
- **`companies.unbilledWork`:** the join-truncation notes now also ride a *skipped* time-entries part (an empty join set
|
|
315
|
+
built from a partial read is not a true zero).
|
|
316
|
+
- **`operations.aggregate`:** the result gains `totalGroups` (distinct groups found) and `groupsTruncated` (`top` dropped some) —
|
|
317
|
+
before, groups beyond `top` vanished with no signal; and an absent field and an explicit null now fold into one group whose
|
|
318
|
+
served key is `null` regardless of row order (it used to be `null` or `undefined` depending on which row came first).
|
|
319
|
+
- **Composite name siblings:** a picklist value that is missing from a *served* list now gets its own reason row
|
|
320
|
+
(`PICKLIST_VALUE_NOT_SERVED_NOTE`, exported from `node-autotask/operations`), distinct from "the picklist is not exposed" (the field-catalog honesty note).
|
|
321
|
+
- **`companies.searchWithDetail`** enforces a closed opts bag, as `companies.search` does (it accepts only `limit`; an unknown key,
|
|
322
|
+
or the search-only `includeFamily`, is refused pre-request instead of being sent).
|
|
323
|
+
- **Required-field 500s are deterministic.** The vendor answers a missing required field with an HTTP 500 that names it
|
|
324
|
+
(`Missing Required Field: actionType`, `Value does not exist for the required field assignedResourceID`; live-verified). They are
|
|
325
|
+
now classified like the `exceeds maximum length` 500s: `retryable: false`, "fix the value, do not retry", and not counted as a
|
|
326
|
+
service failure by the opt-in circuit breaker; the field is extracted from both wordings (the match is no longer anchored).
|
|
327
|
+
- **Smaller tarball:** source maps are no longer shipped (about 18 MB unpacked, 20 files; `src/` was never in the package).
|
|
328
|
+
- **Tests:** write-invalidation across create/update/delete/invoke/failed writes, `unbilledWork` on the default cache, pool
|
|
329
|
+
isolation, composite name-sibling error branches, `*WithDetail` and `aggregate` edge cases, filter refusal at the invoke
|
|
330
|
+
layer, timezone date-window tables, and the realistic `requestCount` echo in hand-written fixtures.
|
|
331
|
+
- Release bookkeeping: `scripts/published-plan-hashes.json` now records 0.9.0.
|
|
332
|
+
|
|
333
|
+
## [0.9.0] - 2026-10-11
|
|
334
|
+
|
|
335
|
+
**The cache channels are on by default, the full-context composites gain opt-in name/label siblings, and resolve and search misses now say why they are empty.**
|
|
336
|
+
|
|
337
|
+
- **Behaviour changes:** `id` is always included when `includeFields` is set (#67); a non-conformant
|
|
338
|
+
query filter shape is refused pre-request with zero wire instead of being silently dropped (#83);
|
|
339
|
+
the cache channels are ON by default — absent `cache` / `fieldCache` / `resourceNameCache` blocks
|
|
340
|
+
build the default instances (read: 5 min + the plan-derived per-resource table applied as the
|
|
341
|
+
default map, its 1 h reference class being billing_codes, countries, departments, phases, roles and
|
|
342
|
+
services with every other plan resource at 5 min; field: 5 min / 256 entities; name: 1 h / 1024
|
|
343
|
+
entries — exported as `DEFAULT_READ_CACHE_TTL_MS` / `DEFAULT_FIELD_CACHE_TTL_MS` /
|
|
344
|
+
`DEFAULT_RESOURCE_NAME_CACHE_TTL_MS`), explicit `false` restores the pre-0.9.0 uncached path
|
|
345
|
+
bit-for-bit, and an object block merges over the defaults (`ttlMs` optional; `perResourceTtlMs`
|
|
346
|
+
entries override the default table per resource, operator entry > table entry > `ttlMs`);
|
|
347
|
+
`ClientPool` keys on the credential tuple without building the cache instances (the pool key
|
|
348
|
+
ignores cache config by design, so a credential tuple that already has a pooled client keeps its
|
|
349
|
+
first-built cache config — `cache: false` on a later `pool.get` does not opt that client out)
|
|
350
|
+
(#80). **Upgrade note:** the default table names every plan resource, so an explicit `cache.ttlMs`
|
|
351
|
+
now only governs paths the table does not name — to shorten one resource's TTL use
|
|
352
|
+
`perResourceTtlMs` (e.g. `{ tickets: 10_000 }`), or `cache: false` for no read cache; reads are
|
|
353
|
+
now served for up to the TTL (stale-read window) by default, and a read in flight across a write
|
|
354
|
+
is never stored (invalidation epoch).
|
|
355
|
+
- **`pageDetails.requestCount` is not a total (since 0.1.0):** live-verified, the vendor echoes the
|
|
356
|
+
requested `MaxRecords` there (`MaxRecords: 5` over 13,220 matches → 5; a zero-match page →
|
|
357
|
+
the MaxRecords asked; 500 when omitted). Three consequences: (1) a bare-value `resolve` miss is now
|
|
358
|
+
a complete non-match (`null`, `server-filter`, one call) — before, every miss read the echo as an
|
|
359
|
+
"inconclusive" server answer and ran the bounded client scan, so on any resource above the scan
|
|
360
|
+
caps a miss threw `RESOLUTION_TRUNCATED` instead of returning `null`; the scan is now opt-in via
|
|
361
|
+
`resolution.scanOnMiss: true` (`RESOLUTION_TRUNCATED` is reachable only there); (2)
|
|
362
|
+
`Page.totalPages` is the pages known so far (`page + 1` while `hasMore`, else `page`; `page - 1` for an
|
|
363
|
+
empty page after a full one; 0 for an empty result) — it was `ceil(requestCount / pageSize)`, i.e. always 1; for a real page count use
|
|
364
|
+
`totalPagesFor(await r.count(filter), pageSize)`; (3) `Page.requestCount` is unchanged (the vendor
|
|
365
|
+
value verbatim) but is documented as the MaxRecords echo.
|
|
366
|
+
- **Type and error-text notes for upgraders:** the exported `PickListValue` is now the vendor model
|
|
367
|
+
`{ value: string, label, isDefaultValue?, sortOrder?, parentValue?, isActive?, isSystem? }` — the
|
|
368
|
+
old `{ name, value }` never matched the wire (`name` is removed; read `label`); the resolved
|
|
369
|
+
`ResolutionCaps` gains `scanOnMiss: boolean` (always populated by `resolveConfig`); and
|
|
370
|
+
`time_entries.postedWithin` / `dateWorkedWindow` now refuse an impossible calendar day
|
|
371
|
+
(`2026-02-31`) in UTC as well as under `tz` (they used to roll it over to March) — a shape error
|
|
372
|
+
still echoes the bad input.
|
|
373
|
+
- **New:** `latest` on the bounded query (#70); `fetchByIds` and the company family search (#71);
|
|
374
|
+
`operations.fieldCatalog` `fields` filter (#69) and `labels` opt, and a live status guard on
|
|
375
|
+
`changeStatus` (#72); `operations.expandResourceNames` with the `resourceNameCache` config (#73);
|
|
376
|
+
`postedWithin` truncation flag, live field validation, `opts.tz` timezone-anchored date windows and the
|
|
377
|
+
`dateWorkedWindow` alias, with billing-item and invoice window families (#74); `noteChars` free-text
|
|
378
|
+
cap and `fieldsPreset` includeFields presets (#75); `companies.unbilledWork` and `operations.aggregate` (#82; a join read that fills its page is named in the part note — the time entries then cover the returned tickets/projects/tasks only).
|
|
379
|
+
- **New (SDK-only):** `resolveWithDetail` / `searchWithDetail` on every resource with a primary text field - a miss carries `tried`, the blind-surface stamp and `next` steps (#78).
|
|
380
|
+
- **New (SDK-only):** the opt-in `names` siblings on the full-context composites (`tickets.fullContext`, `companies.fullProfile`, `configuration_items.profile`, `projects.getContext` — additive `names?: boolean` + `maxNameReads?: number` opts, default OFF = byte-identical output) — the `<field>Name` / `<field>Label` siblings ride the existing default-on caches (no new cache; the closed picklist scope is the ticket fields only) with a separate name-read budget (default 20, lowerable never raised; over-budget ids serve `name: null, capped: true` + `truncated`, never an error; blind ids and unexposed picklists serve `null` + the reason row); and `operations.expandResourceNames` `fresh: true` now bypasses BOTH the name cache AND the ReadCache (a rename made elsewhere is served on the very next fresh call, never a stale snapshot) (#77).
|
|
381
|
+
- **Fixed:** field-discovery guidance points at `operations.fieldCatalog` (#68); a fieldsPreset
|
|
382
|
+
prototype-key lookup, a mixed-fields `fetchByIds` group returning a narrowed row, the `changeStatus`
|
|
383
|
+
guard swallowing aborts, impossible `customRange` / `postedWithin` / `dateWorkedWindow` days rolling over (now refused, UTC and `tz`), `yesterday` and `last7/14/30/90` landing a day off on a DST-change day under `tz`, the name/label siblings resolving picklist labels against the vendor's real `{ value, label }` shape (`PickListValue` corrected to the spec), `time_entries.customRange`
|
|
384
|
+
accepting `from` after `to`, a DST gap at local midnight, `latest` above 500 refused without a
|
|
385
|
+
`limit`, a `noteChars` cut splitting a surrogate pair, name-cache writes re-storing a stale name
|
|
386
|
+
after an invalidation, `fieldCatalog` swallowing aborts and turning a drifted response into an empty list; the vendor's singular item envelope refused by `resource_time_off_additional.getByResource` (#89); `invoke` missing the resource holder when a client convenience function shadows it (`authenticate.get`) (#88).
|
|
387
|
+
|
|
388
|
+
- **Internal:** `npm run release:check` (wired into `prepublishOnly`) refuses a patch bump when the plan hash changed (#87); the blind-surface inventory kinds are aligned with the runtime stamps (#84); the #67-#83 review round's findings are closed (#85); the committed `node_modules` symlink is untracked (#86); `source-map-js` 1.2.2 (#58).
|
|
389
|
+
|
|
390
|
+
## [0.8.3] - 2026-10-10
|
|
391
|
+
|
|
392
|
+
**Request paths and pagination cursors can no longer leave the intended endpoint or origin** (#54).
|
|
393
|
+
|
|
394
|
+
- **Central path guard in `buildUrl`** (every request, including the zone re-point retry). A path must
|
|
395
|
+
start with `/`; its path part may not carry a `.`/`..` segment (also percent-encoded, e.g. `%2e%2e`), an
|
|
396
|
+
encoded `/` or `\` inside a segment, malformed percent-encoding, `#`, `\` or a control character; and the
|
|
397
|
+
built URL must keep the base URL's origin. A refusal is a typed `AutotaskConfigError` (`CONFIG_ERROR`)
|
|
398
|
+
with zero wire. This backstops every hand-written special op regardless of its own id handling.
|
|
399
|
+
- **One shared `cursorToPath`** (exported from the transport module) replaces three copies (`base.ts`,
|
|
400
|
+
`special/time_entries.ts`, the scoped walk in `special/tickets.ts`). The server-supplied `nextPageUrl` is
|
|
401
|
+
resolved against the base and **refused** (`ResponseRejectedError`, `RESPONSE_REJECTED`) when its origin
|
|
402
|
+
differs, so page 2 is never sent to another host with the credential headers (previously a cursor such as
|
|
403
|
+
`https://<base-host>@evil.example/...` was followed, even with `hostPolicy.allowedOrigins` set). A
|
|
404
|
+
same-origin cursor has the base pathname stripped, so the `/atservicesrest` prefix is no longer doubled.
|
|
405
|
+
- **Integer checks tightened to safe integers**: `resource_time_off_balances.getByYear` `year`;
|
|
406
|
+
`companies.upsertByName` `companyType`/`ownerResourceID`; `configuration_items.profile` part ids;
|
|
407
|
+
`time_entries.createForResource` numeric pins (`internallAllocationCodeID`/`ticketID`/`taskID`/`roleID`)
|
|
408
|
+
now run `parseIdentifier` and refuse a negative, fractional or unsafe number typed instead of putting it in
|
|
409
|
+
a path.
|
|
410
|
+
- **Invoke layer**: a `number` arg, and the number branch of a `string | number` (id) arg, must be a
|
|
411
|
+
non-negative safe integer; `-1`, `1.5`, `1e21` and `NaN` are refused pre-request with the existing
|
|
412
|
+
wrong-type message.
|
|
413
|
+
- **Behaviour changes for callers**: an absolute URL passed as `RequestOptions.path` is now refused
|
|
414
|
+
(previously concatenated onto the base); an off-origin pagination cursor now throws instead of being
|
|
415
|
+
re-based onto the configured host.
|
|
416
|
+
|
|
417
|
+
## [0.8.2] - 2026-10-08
|
|
418
|
+
|
|
419
|
+
**Unsafe-integer record ids are now addressable**, plus the worktree tooling that landed with it.
|
|
420
|
+
|
|
421
|
+
- **Ids beyond `Number.MAX_SAFE_INTEGER` are reachable.** `parseIdentifier` now accepts a safe-integer
|
|
422
|
+
`number` or a canonical non-negative integer **string**, carrying the exact digits to the wire with no
|
|
423
|
+
`Number` coercion and therefore no precision loss. A JS `number` that large is already corrupted, so
|
|
424
|
+
unsafe *numeric* inputs stay refused with the typed error — previously the gate refused **both** forms,
|
|
425
|
+
leaving records the vendor addresses fine unreachable through the SDK.
|
|
426
|
+
- **The guard covers the whole id surface, not just the primitive accessors.** Special-operation methods
|
|
427
|
+
(`tickets.summary`/`timeline`/`slaHealthCheck`, `invoices.markupHtml`/`markupPdf`,
|
|
428
|
+
`time_off_requests.approve`, `resource_time_off_balances`/`resource_time_off_additional.getByResource`,
|
|
429
|
+
`inventory_products.stockedItemsAdd`) interpolated an unguarded `number` into the request URL — an unsafe
|
|
430
|
+
number was sent silently corrupted. All now route through `parseIdentifier`, as do the per-element sites
|
|
431
|
+
(`resources.transferOwnership`, `ticket_checklist_libraries.attachToDocument`, `companies.fullProfile`,
|
|
432
|
+
`contacts.moveToCompany`, `time_entries.createForResource`); `fetchMany` accepts canonical integer strings
|
|
433
|
+
end-to-end.
|
|
434
|
+
- **The generator can no longer re-introduce the hole.** The special-op argument tables derive
|
|
435
|
+
`"number | string"`, the registry and both projections were regenerated, and the input-signature audit no
|
|
436
|
+
longer skips the special/`operations` surface (a negative test proves the audit bites).
|
|
437
|
+
- **Tooling:** `npm run worktree:setup` / `worktree:remove` (+ `CONTRIBUTING.md`) link a fresh worktree's
|
|
438
|
+
`node_modules` from the primary checkout, so `tsc`/`vitest`/`eslint` resolve there and gates stop failing
|
|
439
|
+
for the wrong reason. Lossy numeric literals were dropped from the test fixtures
|
|
440
|
+
(`no-loss-of-precision`), keeping `npm run verify` green end to end.
|
|
441
|
+
|
|
442
|
+
## [0.8.1] - 2026-10-07
|
|
443
|
+
|
|
444
|
+
**Publish-and-gate hardening, plus three runtime changes** (the last three bullets: the Node >= 24 floor,
|
|
445
|
+
the per-tenant 429 cooldown gate and `details` on the MCP error payload). Those were first written up under
|
|
446
|
+
`[0.8.0]` with a "v0.9.0" label, but the tags show they first shipped in this release — v0.8.0's tree has
|
|
447
|
+
none of them. The capability plan hash is untouched.
|
|
448
|
+
|
|
449
|
+
- **Publish readiness:** `prepack` + `prepublishOnly` (`clean && verify && capabilities:check:ship`) are now
|
|
450
|
+
wired, so `npm pack` / `npm publish` can never ship a stale or partial `dist`, and a publish always runs
|
|
451
|
+
the full gate chain first. (node-hudu parity; the chain was previously manual.)
|
|
452
|
+
- **`verify:pack` — new, and in the verify chain.** Packs, asserts every `files[]` artifact is inside the
|
|
453
|
+
tarball, installs the tarball offline into a throwaway project, imports/requires **every value subpath in
|
|
454
|
+
ESM and CJS**, resolves the two root JSON catalogs through the documented `createRequire` path, and
|
|
455
|
+
re-asserts the plan hash. Only a tarball install proves a consuming MCP server can import what shipped.
|
|
456
|
+
- **`public-surface` hardened.** It now reads the *built* surface, not just `src/`: every bundler entry must
|
|
457
|
+
have a matching `exports` key **and** every `exports` key a matching entry; every declared target must be
|
|
458
|
+
emitted, under `dist/` and covered by `files[]`; no bare-specifier/runtime-dependency leak in shipped dist.
|
|
459
|
+
This is the rule that catches the defect fixed here: **`src/credentials.ts` and `src/credential-shape.ts`
|
|
460
|
+
were bundler entries with no `exports` key — compiled bytes no subpath could reach.** Both are internal
|
|
461
|
+
(the root barrel re-exports every symbol) so the entries were removed; `scripts/credential-preflight.mjs`
|
|
462
|
+
imported `../dist/credential-shape.js` by path and was repointed to `../dist/index.js`. A new rule blocks
|
|
463
|
+
that class: no in-repo consumer may reference a `dist/…` file no subpath declares.
|
|
464
|
+
- **`mcp:check-surface` gains a credential-leak scan**, run before any spawn: a bearer header, a
|
|
465
|
+
credential-shaped literal, a key-shaped hex value (with the plan hash scrubbed) or a credential field
|
|
466
|
+
declared in a served schema fails the gate over the shipped artifacts.
|
|
467
|
+
- **`n8n:project:check` was orphaned** — present as a script, absent from both `verify` and CI. Wired into
|
|
468
|
+
both. On its first real run it **failed** on the committed `N8N_MCP_MANIFEST.json`: a provenance-only drift
|
|
469
|
+
(`generatedAt` + `capabilitiesHash`) with the operation sets unchanged. Regenerated; the manifest is again
|
|
470
|
+
the projection of the registry rather than a stale copy.
|
|
471
|
+
- Each new or extended gate was proved able to fail (entries↔exports on the real pre-fix state; a declared
|
|
472
|
+
target with the file moved away; a planted bearer literal and a planted credential field; a stale catalog
|
|
473
|
+
count; a deep `dist/` import; the shipped JSON removed from the pack) — non-zero exit, then restored.
|
|
474
|
+
- **Runtime:** the minimum supported Node version is now 24 (issue #8): **BREAKING for anyone still on an older Node** — `engines.node` is `>=24` (the `>= 18.19` floor was set for the n8n 18-era community-node import target, which is gone: n8n 1.100+ requires Node >= 20.19 and the current n8n release (2.41.6) requires Node >= 24; Node 18 is past end of life). CI matrix is `[24]` (the 18.19/20/22 entries are retired — no entry below the floor remains; 24 is the current LTS line and the runtime n8n runs on). Build targets follow the floor: tsup `target: 'node24'`, tsconfig `target`/`lib` ES2023 (behaviour unchanged — the floor only removes restrictions; the HTTP surface gate had no Node-version skip to retire — verified, the probes run on every matrix entry as-is). Docs (README, ARCHITECTURE.md, docs/API.md, docs/LIVE-TESTING.md, SCOPING.md, HANDOVER.md) no longer claim Node 18 support. Package version is not bumped purely for this change (pre-1.0 — the non-goal from the issue)
|
|
475
|
+
- **Runtime:** per-tenant 429 cooldown gate (issue #6): once the transport surfaces a 429 (retries spent, a `Retry-After` too long to wait out, or a non-retriable method), further calls for that tenant fail LOCALLY — before the semaphore, the hourly limiter and fetch, with zero upstream calls — as a `RateLimitError` (`code: 'RATE_LIMITED_COOLDOWN'`, `notSent: true`, `retryAfter` = remaining seconds, no credentials in the message) until the deadline passes. New `cooldown` config (ON by default, `enabled: false` disables): `maxMs` caps the window at 15 minutes (a hostile `Retry-After` — huge, zero, negative, non-numeric — is clamped to the cap or falls back to the `defaultMs` window of 60 s, so a lock-out is always bounded); the thread-threshold 429 (`Itgenatr005`) arms only a short window (min(`Retry-After`, 10 s), default 5 s). Tenant key = lowercased username + lowercased host (impersonation does not change it); a different username or host is unaffected. The default `CooldownStore` is the in-process in-memory map shared by every client instance (capped at 1024 entries, evicting the earliest deadline first — failing open; deliberately not `globalThis`-anchored — the two-maps case for processes loading both builds is documented, and `cooldown.store` injects one shared store, the interface leaving room for a Redis-backed one). A 2xx from a call that got through clears the gate early; expiry is lazy; `thresholdInformation.get()` bypasses the gate so a throttled tenant can still read its own headroom (a 2xx clears, a 429 re-arms). Gated calls emit an audit event marked `notSent` and do not consume `rateLimit.perHour` stamps; an already-aborted caller signal still produces the abort error. New exports: `CooldownStore`, `InMemoryCooldownStore`, `defaultCooldownStore`, `resetDefaultCooldownStore`, `buildTenantKey`, `cooldownDurationMs`, the cooldown defaults (`RequestOptions.bypassCooldown` is SDK-internal, never a caller surface). `parseRetryAfter` now rejects signed numbers (V8's lenient `Date.parse` parsed `'-5'` as a year, which would have surfaced as a 0-second cooldown for a hostile header). Limits (documented in docs/API.md): the gate is per process (other processes/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 loading both the ESM and CJS build gets two default stores. Tests: 19 in `test/cooldown.test.ts` (arm-on-429, local-fail-before-deadline with zero fetch calls, pass-after-deadline via lazy expiry, per-tenant isolation incl. username casing + impersonation, store injection (shared and separate), hostile Retry-After clamping, Itgenatr005 short window, thresholdInformation bypass + early clear + re-arm, default-store sharing across instances + reset helper, aborted-signal precedence, `notSent` audit event, no hourly-stamp consumption, store cap/eviction, fail-closed config validation)
|
|
476
|
+
- **Runtime: the additive `details` extras ride the MCP tools' served error payload (defect found by the stage-12 live verification pass):** the mode tools' error payload passed `AutotaskError.details` through verbatim — previously the serializer's fixed field list dropped it, so the typed `emptyReason` / `pointerNote` (issue #14), the `operations.recovery` refusal payload and the issue #13 shape-drift extras existed on the typed client but were ABSENT from the wire surface agents consume (a pinned op's vendor 500 surfaced as a bare unexplained fault — the live `time_entries.getPosted` W-D3 wall). An error without extras serves no `details` key (unpinned surfaces byte-identical on the wire); the content is plan-derived or the caller's own echoed token — never a credential, never new vendor free text. `errorContent` is exported for direct unit coverage; API.md §1.5 and the N8N-ADAPTER.md error-contract field list document the field. Live-verified on ww29 (the W-D3 500 serves the pointer note over the MCP wire on the merged build).
|
|
477
|
+
|
|
478
|
+
## [0.8.0] - 2026-10-07
|
|
479
|
+
|
|
480
|
+
- **Credential hardening against the shell-quote / CR incident class (issues #46/#47/#48/#49).** A `.env` value single-quoted on purpose (a secret containing `$` must be single-quoted so the shell does not expand it) was being handed to the vendor with its quotes intact by a quote-preserving loader; the value is printable ASCII, so it passed every transport check and came back as an **opaque 401 that read like bad credentials** — the failure that cost three probe cycles before anyone suspected the load. Four additive, zero-wire controls close the class end to end: **(C1) shape validation at construction and at the wire boundary** — `apiIntegrationCode` / `userName` / `secret` / `impersonationResourceId` refuse a surrounding single/double quote pair or an interior CR with a typed, VALUE-FREE `AutotaskConfigError` (code `INVALID_CREDENTIAL_SHAPE`, message names the field + "value appears shell-quoted or line-break contaminated — check how your env file was loaded (single-quoted values must be shell-sourced, not parsed by a quote-preserving loader)"); padding-only whitespace still trims exactly as before, and the existing presence / non-printable guards are untouched. **(C2) canonical loader** — new `src/credentials.ts` exporting `loadAutotaskCredentialsFromEnv(env?, names?)` + `loadAutotaskCredentialEnvFile(path, names?)` (+ `readCredentialEnvFile` / `parseCredentialEnvText` / `normalizeCredentialValue`) for the `AUTOTASK_TRACKING_IDENTIFIER` / `AUTOTASK_USERNAME` / `AUTOTASK_SECRET` names plus the optional `AUTOTASK_IMPERSONATION_ID` and the deprecated aliases, with shell-equivalent value handling (ONE pair of matching surrounding quotes stripped, CRLF-safe, **no `$` expansion**), presence errors as today and shape errors via C1; JSDoc states the single-quote contract (`set -a; . ./.env; set +a`), and the live scripts are wired to it. **(C3) 401 shape annotation** — the 401 path runs the C1 checks against the credential values the client actually SENT and appends a value-free `details.diagnostic` ("sent value shows shell-quoting/line-break artifacts — check your env load (…)" vs "sent value shows no shape anomalies — credentials or account state are the likely cause") plus a `pointerNote` to the canonical loader; the message and `suggestedAction` are **unchanged** (append only — a pinned 401-text assertion was NOT weakened), a direct `errorFromStatus(401, body)` call behaves byte-identically, and 401 is still never retried. **(C4) live preflight + `--dry`** — `scripts/credential-preflight.mjs` runs the SDK's own shape checks before any network call and `--dry` prints a value-free summary (LENGTH + 2-character prefix only, withheld below 4 characters) with a GREEN / SKIP / RED verdict and exit 2 on a contaminated value; both `scripts/live-smoke.mjs` and `scripts/live-coverage.mjs` carry the gate, documented in `docs/LIVE-TESTING.md` §0b ("run `--dry` first; a green dry run makes any later 401 vendor-side") and the README "Credentials & env files — the single-quote contract". Additive public surface: `loadAutotaskCredentialsFromEnv`, `loadAutotaskCredentialEnvFile`, `readCredentialEnvFile`, `parseCredentialEnvText`, `normalizeCredentialValue`, `credentialShapeProblem`, `hasSurroundingQuotes`, `credentialValueSummary`, `CREDENTIAL_ENV_LOAD_ACTION`, `MIN_PREFIX_SUMMARY_LENGTH`, `mergeDetails`, `CREDENTIAL_SHAPE_POINTER` + the types `AutotaskCredentials`, `AutotaskCredentialNames`, `CredentialEnvLine`, `CredentialShapeProblem`, `CredentialShapeOptions`, `CredentialShapeExpectation`. Port of the node-hudu credential-hardening (node-hudu#91–#94, PR #95). ZERO vendor dials; no credential value is ever printed (lengths + 2-char prefixes only). No new operation and no plan/generator change — the capability plan hash is untouched. Tests: 49 new pins across `test/credential-shape.test.ts` (20), `test/credentials.test.ts` (22) and `test/live-credential-preflight.test.ts` (7), all zero-wire.
|
|
481
|
+
|
|
482
|
+
- **Zone re-resolution on 401 (issue #43).** A tenant is pinned to ONE base URL at construction time (a named zone from the static 23-zone catalog, or an explicit `baseUrl`); when the vendor moves a tenant (a data-center migration), every request to the stale catalog URL answers 401 and the typed `UnauthorizedError` propagates looking like bad credentials — when the fix is "the tenant now lives at a different host". New opt-in top-level flag `reResolveZoneOn401` (OFF by default — absent or not exactly `true` is today's behaviour byte-identically: a 401 surfaces the typed error with no discovery call and no second request): a 401 on a RELATIVE path against a CATALOG base makes ONE unauthenticated vendor zone-discovery call (`GET https://webservices.autotask.net/atservicesrest/v1.0/zoneInformation?user=<userName>` — no auth headers on the `zoneInformation` wire; bounded by the same whole-call deadline as the recovered request) whose response `url` is the ONLY accepted re-point target (a 401 body-injected URL never receives the credentials). If the resolved zone base DIFFERS from the client's live base: the client re-points its live base URL (process-lifetime — never persisted; observable via `http.baseUrl`, the configured `config.baseUrl` stays the construction-time value; a warn log names both bases) and retries the SAME request (method/path/body/operation) ONCE against it. The WYRE prior-art gates are adopted verbatim: an explicit `baseUrl` (a bare one or `zone: 'other'` + baseUrl) is NEVER re-pointed (the operator said where to go); exactly ONE retry — a request-local no-loop flag, never a loop; an ABSOLUTE path (a cursor `nextPageUrl`, already baked to a host the client does not own) is never re-pointed. A resolved base IDENTICAL to the live one (genuinely bad credentials), or a discovery fault (network / non-200 / redirect / malformed / missing or non-http(s) `url`), propagates today's typed 401 byte-identically (zero retry — the recovery must not turn a credential fault into a discovery fault); a retry that 401s again keeps its typed identity and gains the self-explaining `details.zoneRecovery = { from, to, retried: true }`. State hygiene: the transport's per-host state (the tenant rate key and the circuit-breaker key, both keyed on the base URL) is re-derived on the re-point, so the re-pointed host starts FRESH — no cooldown/breaker/tenant-rate inheritance from the stale host (conservative: the stale host's armed entries stay in the shared stores for the other clients that still talk to it). Non-goals: no zone auto-discovery at construction (the node keeps requiring a zone or a baseUrl), no persistence of the re-point, no retries of any other status, no served MCP/n8n text change (transport behaviour, like #34). Pins: `test/transport-zone-reresolve.test.ts` (10 pins, mock wire only — no vendor wire in tests). QA minors applied pre-merge (single flash lens APPROVE 0 blocking / 4 minor + the coordinator mutation pass): the top-level-`url` body shape added to the never-a-target pin (a body-reading mutation now fails it), the no-loop pin made mutation-sensitive (the queue holds a second discovery answer a guard-dropping build would consume — the call-count assertion dies), §11.19 now states the cursor-after-re-point consequence (a pre-re-point cursor is re-based onto the new base — a cross-host walk), the `http.baseUrl` getter source-compat note, and the oversize-discovery-body fault in the discovery-fault list. The local-lens finding (breaker probe host-pinning) applied as well: a half-open probe admitted on the stale host is pinned to that host's breaker key — a mid-call re-point voids it, so the re-pointed host's probe budget / close count is never touched by a stale-host probe (phantom probe successes could have closed its circuit early); pinned (PIN 11, mutation-verified).
|
|
483
|
+
|
|
484
|
+
- **FK caching for the ticket composites (issue #36).** `tickets.getContext` / `tickets.fullContext` are the single hottest chatty paths for agent workloads: every call pays the ticket read plus up to four single-record reference-table reads (company, contact, assigned resource, project) — a repeat call on the same ticket paid that whole read again, and the ticket root itself was served from the read cache when it should be the live record. Reuse, not reinvention (no second cache, no new config surface beyond the sibling per-resource TTL, no new ops): **(1) the reference-table FK parts ride the normal cached get-by-id path** — the #32 keying stores each part under the read entity's path with that entity's per-resource TTL (the plan-derived 5min non-reference class via `DEFAULT_PER_RESOURCE_TTL_MS`, or the operator's `perResourceTtlMs` pin), which this pins for the composites and pairs with the ticket read becoming FRESH (`fetchRecordRaw` with `fresh: true` — the composite root is always the live record; the read still refreshes the ticket entry for the other callers). Scope: the ticket composite only — the other workflow composites keep the current (cacheable) root behaviour, and the #35 child reads (notes / time entries / attachments / histories) stay uncached. **(2) the negative-hazard is pinned both legs** — a vendor NOT-FOUND on an FK part (the 404 or the 200 `item: null` signal) is stored as an 'absent' marker in the SAME `ReadCache` under a namespaced key the positive path never constructs (the `.get` / `resolve` single-record read paths are bit-for-bit untouched): the repeat composite serves 'absent' with zero wire for that part; the marker expires on the read entity's TTL and dies with any real write through the client; a transient fault (5xx, shape drift, network) is never stored as a marker — it propagates and the next call re-wires the part (no poisoning; a malformed 200 body may still land under the positive key — pre-existing read-cache behaviour). Newest outcome wins in BOTH directions: a positive entry that exists alongside a live marker (this client read/created the record after the not-found) drops the stale marker and serves the record — a record that exists is never reported absent while a live positive read of it is cached (QA fix, pinned; the dry-run parent-exists check shares the mechanism and inherits the clearing). The documented trade: a record created ELSEWHERE (never read by this client) may serve 'absent' until the marker TTL (or a write through this client lands) — the opt-in staleness contract. **(3) mutation clearing is inherited, not built** — any real write through the client clears the whole read cache (the existing transport behaviour), covering the FK parts and the negative marker together (the "someone changed a company name" case). Off-by-default stands: with `cache` unset every composite call is bit-for-bit the pre-#36 wire and result. Pins: 10 in `test/resources/context-fk-cache.test.ts` (repeat `getContext` wires exactly the fresh ticket and zero reference parts; the cache-unset default double-wires both calls; the 404 negative-cache leg and the 5xx no-poisoning leg; the 200 `item: null` marker leg; the positive-read-clears-stale-marker rule (the QA blocking fix); the write-clearing refetch; the per-resource TTL table + override on the FK parts, fake-clock; the `fullContext` repeat with the #35 child reads re-wiring), mock wire only; docs section 11.18. QA minors applied pre-merge (single lens, deepseek-v4.1-flash): the BLOCKING stale-marker shadow (a record this client read positively was still served 'absent' by the composite, and the marker leaked into the dry-run parent-exists check — fixed by the positive-outcome clearing + two mutation-verified pins), the unpinned 200-`item: null` marker leg (now pinned; the mutation survives no pin anymore), the 'shape drift never stored' overclaim (it is never stored AS A MARKER; the positive-key behaviour is pre-existing), and a dropped blank line before section 12.
|
|
485
|
+
|
|
486
|
+
- **Composite child reads recover on the plan-declared scoped route when the global route is unavailable in a zone (issue #35).** Live zone evidence (ww29): the GLOBAL ticket child routes can be unavailable where the plan-declared scoped paths exist — `POST /V1.0/TicketHistory/query` 500s (the W1 contract: one and only one ticketID equals filter) and its item route 500s (`field:id` not queryable), while `/V1.0/Tickets/{parentId}/Notes` / `…/Attachments` are plan-declared — and `tickets.fullContext`/`tickets.timeline` (the flagship one-call composites) failed (or poisoned) the WHOLE call when one child route was down. Narrow recovery, recorded, never silent: **(1) plan-declared scoped fallback only** — the composite child reads (notes, time entries, attachments, opt-in histories) try the global `/query` route first (the working-zone contract is untouched: same wire, byte-identical part shapes, no new keys), and ONLY on 404/500 from that route do they recover on the plan-declared parent-scoped collection read (`GET /V1.0/Tickets/{parentId}/Notes` / `…/Attachments` — derived from the plan-derived relationship map, never a guess: `time_entries`/`ticket_history` carry no plan-declared scoped route and get none); a non-404/500 failure still propagates and NOTHING changes in the transport (a generic 404 fallback would mask real wrong-path bugs); **(2) a route change is a status fact** — `fullContext` list-part status extends `'ok'` → `'ok' | 'ok-scoped' | 'miss'` (additive; the FK `absent` rule stands): an `'ok-scoped'` part carries `route: 'scoped'` (the attachments METADATA part too), and a `'miss'` part (every declared route failed — the call itself still succeeds) serves `{ items: [], capped: false, routeFacts }` where `routeFacts` records EVERY route attempted, in order, with path + HTTP status (the global route always, the scoped route only where attempted — a global 404 + scoped 404 reports both facts); `timeline` gains an optional `sourceRoutes` map (per-source `'ok' | 'ok-scoped' | 'miss'`) present only when a source deviated from the global route (absent in the working zone); **(3) the scoped fallback walk** — a bounded `GET` on the vendor cursor (10-page cap), capped with the same limit semantics as the global read (a full limit → `capped: true` even when the cursor ran out exactly at the limit — the QA-aligned rule; or a live cursor at the page-cap stop), applying the timeline `since`/`until` date window CLIENT-side (the scoped read has no server-side filter; the ticketID scoping is intrinsic to the path; an unknown filter operator excludes the row — the fallback never widens the result set). No new ops — the existing `tickets.fullContext`/`tickets.timeline` tools serve the additive status fields (the plan edit is the served usage/args text only: the working-zone wire count and the degraded-zone per-part recovery). QA minors applied pre-merge (dual gate: flash APPROVE 0 blocking / 5 minor + strix local APPROVE 0 blocking / 2 minor): the registry-predicate guard (the child must declare a query op in the registry — a child with none has no declared global route to recover from and is never given a scoped read), the scoped `capped` boundary aligned with the global branch, and pins for the persistent-500 blind-retry budget interaction (1 + the default 3 attempts before the recovery fires), the 10-page cap (33 rows over 11 pages → 30 served, capped, the 11th page never requested), the unknown-operator exclusion (pinned directly — unreachable through the public API today, where the composite filters use only eq/gt/lt), the guard WIRING pin (a parent-scoped child with no registry query op — `ticket_checklist_libraries` — is never granted a scoped read; removing the guard line hands it a guessed path), the docs precision clauses (a 200-with-wrong-shape on the scoped route propagates as the typed error — the recovery never papers over a shape fault; `timeline`'s `sourceRoutes` carries the status words only, no `routeFacts` channel), and the dead `overflow` counter the aligned rule no longer reads. Pins: 16 in `test/resources/tickets.composite-scoped.test.ts` (the 12 original + the 4 QA pins), mock wire only; docs section 11.17.
|
|
487
|
+
|
|
488
|
+
- **The opt-in, strictly-gated PATCH-404 -> PUT item-path fallback for updates (issue #34).** A zone can stop registering the update route the spec pinned (WYRE-AI hit exactly this on Zone DE1 — their IIS does not register the collection-level PATCH route at all, their issue #133 — and every update dead-ends with no recovery; their own prior art documents the hazard: "PUT sets every writable field missing from the body to null, so the fallback turns a partial update into one that clears the rest of the record"). New: **`update: { putFallback?: boolean }`** — OFF by default (absent or not exactly `true` = the spec-derived route contract is unchanged: a 404 surfaces today's typed error byte-identically, no merge read, no second request). With it ON, a transport `NotFoundError` with `httpStatus: 404` (CLASS-keyed — a 404 body carrying a machine-like vendor code adopts it into `err.code`, but the class identity holds, so a body cannot skip the recovery; never 400/405/422/5xx or a 2xx shape fault — those keep today's typed error byte-identically) on a NON-collectionUpdate update route retries ONCE as `PUT /{Entity}/{id}`: the scoped collection PATCH (`updateScopedRaw`) falls back to the scoped item path, and a top-level item-path PUT re-issues its own route (defensive — the current registry has no such resource, every top-level update is collectionUpdate, and the branch is pinned by a test double). The retry body runs the SAME fresh-read merge the collection-update path performs: the caller's fields merged onto a FRESH unredacted read of the current record (new `getScopedUnredacted` sibling of `getGlobalUnredacted` — live, never cached, `skipResponseRedaction`), so a fallback PUT is never a null-clearing partial (pinned on a REAL scoped PII resource — a company-scoped contact's live `emailAddress`/`phone` values reach the retry wire body, and a dropped unredacted-read flag is caught by that pin, not by a test double). Never retried: collectionUpdate resources (the spec defines no item-path update there — the item path is GET-only and 405s) and `raw: true` updates (the issue-favoured "no fallback" semantics — the raw path issues exactly what it was told, no merge by definition). A declined gate (a collectionUpdate route 404s, or the retry itself 404s) keeps the `NOT_FOUND` identity (code / httpStatus) and makes the error self-explaining: the message names the route tried, the status and the opt-in, `details.putFallback` carries `{ route, status, retried, optIn }`, and the `suggestedAction` points at the zone's route registration. A target miss on the fallback merge read surfaces the pinned update-target reason (issue-#14 convention) with no retry issued. Exactly one retry — no loop; the first attempt's wire is unchanged (the partial goes out as-is before the fallback is even considered), and `dryRun` is untouched (it short-circuits before the wire). No plan/generator change (the update records are untouched — the fallback is a config-gated transport behaviour, not a new operation; the MCP/n8n surfaces are unchanged). Tests: 14 pins in `test/resources/put-fallback.test.ts` (the fault-injection fixture: recovered scoped 404-then-PUT-200 with the merged unredacted body, the scoped-PII live-value round-trip (real company-scoped contact), the machine-code-404 recovery (the class-keyed gate), the off-by-default byte-identical 404 with zero second requests, the null-out hazard, the collectionUpdate gate decline, the raw no-fallback, the 400/405/422/429/500 never-retry, the top-level item-path recovery/decline/merge-miss via the test double). QA minors applied pre-merge: the class-keyed gate (a body-carried vendor code could previously skip the opt-in recovery), the scoped-PII pin (the unredacted-read guarantee was unpinned on the scoped route — a real PII scoped fallback could have written `[REDACTED]` markers onto a record), the 429/500 pins, and the docs reachability wording. Docs: the API reference config table row + section 11.16 (numbered after the issue #32 section, which lands from its own branch).
|
|
489
|
+
- **Per-resource read-cache TTLs: reference data and hot records age at different rates (issue #32).** One cache `ttlMs` forced the operator to choose the worst case for the two very different reads an agent session mixes — reference tables that change on a human timescale (countries, departments, roles, billing codes, services) and records that change minute to minute (tickets, time entries). New (additive; the R-F3a off-by-default contract and every existing cache pin stand bit-for-bit): **(1) `cache: { ttlMs, perResourceTtlMs? }`** — a resource plan key -> ms map validated in `resolveConfig` fail-closed exactly like `ttlMs` (wrong type / non-integer / non-positive → `AutotaskConfigError` at construction); a named resource expires on its own TTL, every other resource (and an absent map) falls back to `ttlMs`; **(2) the pinned key contract** — the entry's TTL is the override for the **plan key of the entity the read path points to** (the cache key stays the wire path): a cross-entity read expires on the READ entity's override (a contact read through `tickets.getContext` is bounded by the `contacts` override, not the ticket's), and a path the plan does not name falls back to `ttlMs`; **(3) the plan-derived default table** — `src/cache-ttl-tables.ts` (the 7th generated artifact, planHash-stamped, regenerated with the plan by `generate-capabilities`): the reference SET is a pinned design decision (the issue's own motivation + the prior-art WYRE-AI `calculateResponseTTL` — the plan carries no staleness marker), the table STRUCTURE is one row per plan resource — reference class 1h vs default class 5min — so a plan change regenerates the split; `DEFAULT_PER_RESOURCE_TTL_MS` is a reference for building the map (spread + per-operator pins), never applied implicitly; **(4) docs + gate** — `docs/API.md` §11.15 + the §3.2 defaults row, the `check-capabilities` clause 8a re-derivation, and `test/scripts/cache-ttl-artifact.test.ts` (the byte-equal drift pin: the table regenerates with the plan). MCP: nothing new served; the config is operator-side.
|
|
490
|
+
|
|
491
|
+
- **The opt-in live field-metadata channel: one config block, one read operation, one MCP tool (issue #31).** The vendor moves field requirements without warning (8 drifts in 48 h in the `confirm-100-20261006` ledger, two of them changing *required-field* reality), so the point-in-time pins (`operations.createGuidance`, the `zoneFacts` lists) are only as fresh as their `verifiedAt`. New: **(1) `fieldCache: { ttlMs, maxEntities? }`** — a separate opt-in block from `cache` (different key, lifetime and invalidation signal); with it unset the `getEntityInfo` / `getFieldInfo` / `getUdfInfo` trio is the uncached wire path, bit-for-bit; when set, the trio reads through a new `FieldCache` (`src/fieldCache.ts`, the `ReadCache` idiom: `structuredClone` on get/set, lazy purge, capped at `maxEntities` (default 256) with oldest-inserted eviction) that additionally **never caches an empty or failed result**, **de-dupes in-flight reads** (concurrent callers share one wire request), and exposes first-class **`invalidate(resourceKey)`**; **(2) drift invalidation** — a deterministic vendor field-validation 500 (`details.deterministic: true`, issue #23) or an `INVALID_RESPONSE` on the metadata trio invalidates the ONE entity the call named so the next metadata read refetches; an ordinary record write never does (a write says nothing about the field catalog); **(3) `operations.fieldCatalog(client, opts?)`** (`{ resources?, include?, fresh? }` — default the five workflow resources, `include: ['fields']`) — one bounded entityInformation GET per resource × kind, the wire cost `resources × include` REFUSED pre-request above the pinned cap (`MAX_FIELD_CATALOG_WIRE = 60`, never truncated), per-resource vendor failures recorded on the row and never aborting, capability PLAN-DERIVED (the `getFieldInfo` / `getUdfInfo` row sets + the endpoint's vendor path segment — never hardcoded; a known key without a field catalog is refused typed, an unknown key → typed `RESOURCE_UNKNOWN` with the count + the `autotask_catalog` pointer), and the result carries the stable `honesty` block: the live catalog reports field SHAPE and serves picklist VALUES where the vendor exposes them, but CANNOT resolve a picklist VALUE the vendor does not expose to this API user (the W-A4 wall — retrying the catalog will not surface a withheld value); **(4) `autotask_field_catalog`** — the extended-tier MCP projection of the op (the 15-tool core surface untouched), with the same W-A4 honesty sentence in the served description (the S13-QA rule: the served text states only what the closed bag can do); **(5) docs + gate** — `docs/API.md` §11.14 + the §3.2 defaults row, and `check-capabilities` clause 17 (with the `test/scripts/g16-field-catalog.test.ts` tripwire) pins the channel: the mandatory plan row, the documented closed opts bag, the W-A4 sentence in the served purpose, and the `udfCapable` / `getUdfInfo` / `getFieldInfo` row invariants (the op's accepted `resources` filter can never be wider than the plan's `getFieldInfo`-bearing set — fail-closed).
|
|
492
|
+
- **The served MCP error payload carries the redacted `vendorError` (issue #33).** The typed error already kept the vendor's response body under `vendorError` (deep-redacted once in `errorFromStatus`), but `errorContent` in the MCP reference servers built the served payload from a fixed field list that dropped it — an LLM client saw `SERVER_ERROR` + status + `suggestedAction` and no reason, so its only recovery move was to retry a call that may be deterministically impossible (the live sweep's five UNVERIFIED rows: the vendor's field-level text is what separates "this zone does not have /TicketHistory queryable" from a transient outage). `vendorError` now rides the served payload under a fixed key: a string body is host-sourced free text and gets the SAME `<untrusted_content>` treatment as `details.bodySnippet` (wrapped when the untrusted-content projection is on, raw when off); a parsed (non-string) body is served verbatim (the redacted copy — nothing to wrap); an absent/empty body emits NO key, so unpinned payloads stay byte-identical on the wire. No change to error codes, `retryable` or `suggestedAction`; the deep-redaction runs upstream, unchanged. The N8N adapter needs no code change — it re-serves the same MCP error text. QA minors applied pre-merge: the n8n `GOVERNANCE.errorContract` sentence (and the manifest + docs/N8N-ADAPTER.md) now names `vendorError` too (no adapter code change — it re-serves the same error text); and a parsed (non-string) vendor body's string leaves get the same marker neutralisation `wrapUntrusted` applies when untrusted-wrapping is on, so a vendor body containing the literal wrapper markers cannot forge a boundary (raw when wrapping is off — a redacted copy). Docs: the API reference's served-error-payload contract (section 2) and the N8N adapter's error contract name the field + the wrapper caveat.
|
|
493
|
+
|
|
494
|
+
- **MCP served-surface accuracy pass (top-20 audit, 2026-10-06): descriptions now state only what the closed schema can do.** A two-auditor pass over the 196 dedicated tools of the 20 highest-op endpoints (65+69 OK / 19+22 MINOR / 15+6 ISSUE, 0 unverified — `.run/top20-audit-20261006/findings-{A,B}.md`) found the served text promising things the closed `inputSchema` cannot deliver and the refreshed 2026-10-06 zone walls missing from the surface. Fixed at the source (plan purposes + projection + overrides; the server serves the manifest, never re-derives wording): **(1) the 116 over-precise `resolve` purposes** — "from an id or a filterable field value" (a bare value is exact-matched on the primary text field ONLY, and 81 resources have none at all: a non-numeric identifier throws a config error) now state the exact-match contract per resource (the 5 campaign-written precise purposes as template) or "numeric id only" for the search-field-less resources; **(2) the 116 `query` purposes** — the pagination facts stated twice (purpose clause + mechanical bounds sentence) deduplicated to the bounds sentence; **(3) the `raw: true` clause dropped from the 40 collectionUpdate merge-semantics notes** — `raw` is an SDK opts-bag flag, not a served argument (the update schema is closed at id/data/dry_run); **(4) the 14 `createIfNotExists` tools** — the served description now states the plain-create reality (the dedup `dedupFields` bag is not in the served schema, so the promised return-existing branch is unreachable — duplicates are possible; the note is appended mechanically by the projection when the schema lacks the bag, SDK-level dedup semantics stay in the record's usage); **(5) the 2026-10-06 zone walls on the served surface** — the 4 walled `time_entries` write tools name the W-A3 wall ("API-only user cannot be selected for this field", re-verified confirm-100) + the working reads, `getPosted`/`getUnposted` name the W-D3 wall + the `postedWithin` alternative, the 3 walled creates (`inventory_products` blind productID reference, `contract_charges`/`project_charges` blind parent) carry the wall note on the served schema + the describe channel (the `configuration_items.create` fix pattern), and the 3 `createIfNotExists` tools point at the walled sibling create; **(6) the override provenance marker is no longer served** (repo meta-text an MCP client cannot act on — kept on the projection row), the `ticket_full_context` includeHistories opt-in wording (not a served argument) and `company_search_by_domain`'s "optionally" (the email match is an UNCONDITIONAL fallback) corrected, the 4 dry-run duplications de-duplicated, the 2 companies overrides restore the purpose sentence, and the tickets count/query rows carry the ww29 blind-surface quirk (the companies precedent). New machine pins in `test/scripts/mcp-surface-guidance.test.ts` (S13-QA block: no `raw: true` / no provenance marker / no double pagination / every createIfNotExists names the plain-create reality / no `filterable field value` / the wall caveats present — tool sets plan-derived). No wire change, no signature change; the served description text of the affected tools changes (published contract surface — dual-gated).
|
|
495
|
+
- **Pin update (confirm-100 live re-verification, 2026-10-06): zone-fact pins refreshed to current vendor behaviour — no code-path change.** A 2026-10-06 live re-verification pass on the ww29 sandbox (100 vendor calls, run dir `.run/confirm-100-20261006/`) found the vendor moved 3+ times between the 10-04 sweep and 10-06: **(1) W1 `ticket_history.query` is now SATISFIABLE** — the vendor requires exactly one `ticketID eq` filter; the unfiltered read still 500s by contract, but the filtered query WORKS (10 rows live). The pin now describes the contract instead of a wall; `tickets.timeline` / `tickets.fullContext` remain the composites. **(2) W-A3 `time_entries.create` drift** — the vendor now refuses ALL time-entry writes for API-only users ("API-only user cannot be selected for this field"); `createForResource` (previously proven: 8 live creates 10-04) is walled by the same drift. **(3) W-A4 attachments** — `Publish` is validated first and string `Type` values are now silently rejected (the picklist values remain unexposed). **(4) Ten create ops promoted unproven → live-proven**: `contracts.create` (types 1/3/4/6/7/8 with the per-type required fields — `companyID` accepts an owned company, closing the W-A6 "blind parent") and `contract_notes` / `contract_rates` / `contract_exclusion_roles` / `contract_milestones` / `contract_blocks` / `contract_retainers` / `contract_ticket_purchases` / `contract_block_hour_factors` / `ticket_additional_contacts`.create (the type-gating decoded live: milestones→type 3 Fixed Price, blocks→type 4 Block hours, retainers→type 6 Retainer, ticket_purchases→type 8 PerTicket (incident), service adjustments→type 7 Recurring; `ticket_additional_contacts` requires a contact associated with the ticket's company — the 10-04 W-A2 "blind reference" was a company-scoping issue). **(5) The `time_entries` empty-reason pin corrected**: the unfiltered read is owned-only visibility (unfiltered-blind) — DATA EXISTS (12 rows in a live windowed read), it is not a table empty for the user. The residual walls (the service / service-bundle / billing-rule / role-cost / exclusion-code contract children, `ticket_checklist_libraries` — now an HTML 404 parent wall before the W-A7 200-shape drift, plus a NEW W-A7-class 200-shape drift on `contract_service_bundle_adjustments.create`) carry the current exact vendor text. Residue: 13 marker-named records in the delete-absent contract family (UI cleanup; ledger §E).
|
|
496
|
+
|
|
497
|
+
- **New (issue #26): the optional per-host circuit breaker at the transport (OFF by default).** `circuitBreaker: { enabled: true }` opts in: `failureThreshold` (5) counted failures inside `failureWindowMs` (10 s) open the circuit for `openTimeoutMs` (30 s); further calls for that host then fail LOCALLY — before the semaphore, the hourly limiter and fetch, with zero upstream calls and no hourly stamp — as `CircuitBreakerError` (`code: 'CIRCUIT_BREAKER_OPEN'`, `notSent: true`, `retryAfter` = the remaining open seconds, `details = { state, retryAfterMs, failureCount }`). **`retryable: true` is a DELIBERATE divergence from #6's `RATE_LIMITED_COOLDOWN` refusal (`retryable: false`)** — the open period is bounded and advertised via `retryAfter`, so retrying the SAME call after the wait is the correct caller behaviour (service health, not request shape — no narrowing needed). The key is the lowercased HOST of the base URL (host-only, like the zone-outage physics): a different host is unaffected; on a shared-host gateway (one host, many credential tuples) one tenant's outage opens the circuit for that host's other tenants — the documented consequence of the host-only key; `enabled` is off by default so nothing changes unless opted in. Timer-free (state is pure timestamps, derived from `Date.now()` lazily — exactly like #6; no timers). Half-open admits up to `halfOpenProbes` probes (default `= successesToClose`; a further call while the probe budget is in flight is refused with `state: 'half-open'`); `successesToClose` (2) probe successes close the circuit (counters reset, the entry deleted), a probe failure re-arms the open period. `thresholdInformation.get()` bypasses the REFUSAL so a downed host can still be probed for headroom — its outcome is still RECORDED (a 2xx closes toward recovery; a counted failure re-arms; the #6 ThresholdInformation-bypass semantics). The default failure predicate counts exactly: `NETWORK_ERROR`, `TIMEOUT`, a vendor 429 (keyed on the status — a surfaced 429 may carry an adopted vendor machine code, e.g. the thread-threshold `Itgenatr005`), a 5xx EXCEPT the deterministic field-validation 500 (#23's `details.deterministic` signal — by that signal only), and `UNEXPECTED_CONTENT_TYPE` (#24, by code — never `INVALID_RESPONSE`: the W-A7 shape-drift / #13 marker-recovery key is untouched); never `ABORTED` (a caller's cancellation), any 4xx, or a local/policy refusal (`notSent`). New additive public surface: the `circuitBreaker` config block, `CircuitBreakerError`, and the store trio mirroring `src/cooldown.ts` — `CircuitBreakerStore` / `CircuitBreakerState` / `InMemoryCircuitBreakerStore` (1024-entry cap, oldest last-activity evicted first — failing open) / `defaultCircuitBreakerStore` / `resetDefaultCircuitBreakerStore` (the per-process default store survives pool LRU eviction — the state rides the store, not the client), `buildBreakerKey`, `isBreakerFailure`, and the `DEFAULT_CIRCUIT_BREAKER_*` constants. Fail-closed config validation (the #6 pattern): positive integers, `halfOpenProbes >= successesToClose` (a breaker that can never close is refused), store shape, `isFailure` a function. Surfacing breaker state in the doctor is a FOLLOW-UP (not requested by the issue). Tests: 16 in `test/circuit-breaker.test.ts` (the #6 cooldown suite's sibling — same fake-timers + injected-store scaffolding) — open-on-5-failures + zero-fetch refusal with the full contract pin, the probe/close and re-arm cycles, every predicate pin incl. the deterministic-500 / ABORTED / 4xx / cooldown-refusal never-counts, host isolation + same-host sharing, the threshold-bypass success-closes / failure-re-arms semantics, the half-open probe budget, aborted-signal precedence, the fail-closed config validation, off-by-default byte-identical, the store trio/eviction)
|
|
498
|
+
- **Transport response hardening (issues #23/#24/#25/#27): two new error codes, the permanent validation 500, and the typed fault-injection test fixture.** Additive public surface: `UnexpectedContentTypeError`, `EmptyResponseError`, `isDeterministicVendor500` (the #23 predicate — the shared signal for the #26 breaker), `RequestOptions.expectBody`, and the two machine codes `UNEXPECTED_CONTENT_TYPE` / `EMPTY_RESPONSE`.
|
|
499
|
+
- **Behaviour change (issue #25 — strict writes):** a blank 2xx on a NON-DELETE call that expects a result now throws the typed `EMPTY_RESPONSE` (server, `details = { httpStatus, method }`, `retryable: true` for non-DELETE — advisory, the loop never retries 2xx) instead of resolving `undefined`. A blank 2xx PUT/POST used to count as success where the caller discarded the body — a write that answered nothing is now a typed failure (deliberate: not a confirmed success; NO `expectBody: false` opt-outs at the discarded-body write sites). `RequestOptions.expectBody` (default `method !== 'DELETE'`) opts a call in or out; `DELETE` and 204 keep returning `undefined`. **Latent-bug fix shipped in #25:** a vendor 204 / blank DELETE used to throw `INVALID_RESPONSE` — the delete paths now synthesize the result from the numeric path id for a blank answer (`deleteGlobal` / `deleteScopedRaw` → `{ id }`; `deleteScopedModel` → the empty model, never a throw). The transport attaches the issue-#14 typed empty-reason stamp on the new error, so a pinned surface keeps its reason.
|
|
500
|
+
- **Behaviour change (issue #24 — non-JSON 2xx typed by `Content-Type`):** the transport reads the response `Content-Type` for the first time; the decode attempt stays UNCONDITIONAL (JSON under an odd header still decodes). A non-JSON 2xx with a non-JSON-ish header (`text/*` incl. `text/json`, or anything not `application/json*` / a plus-json suffix) now throws the new `UNEXPECTED_CONTENT_TYPE` (server, retryable — advisory) with `details = { httpStatus, contentType, bodySnippet }` — the first 256 BYTES of the raw body, TRUNCATION ONLY (name-based redaction is a no-op on free text) and host-sourced, so the MCP reference servers wrap exactly that value in `<untrusted_content>` when the untrusted-content projection is on (`AUTOTASK_UNTRUSTED_WRAP=off` serves it raw). Retry leg: idempotent methods (GET/PUT/DELETE + the idempotent-flagged `/query` POSTs) retry a non-JSON 2xx per policy (the edge served the page — the vendor never processed the request); a non-idempotent call (a create) makes ONE call and its `suggestedAction` states the write may have taken effect (the #13 residue concern). A JSON-ish or header-less non-JSON 2xx keeps `INVALID_RESPONSE` byte-identically (the #13/W-A7 marker-recovery key) and is never retried. Consequence: a plan-pinned drift resource now SKIPS marker recovery for a non-JSON create answer (an HTML page carries no marker signal) and for a blank 2xx (`EMPTY_RESPONSE` ≠ the `INVALID_RESPONSE` key). Zone discovery rides the same transport — a maintenance-window startup failure is recognisable by the code.
|
|
501
|
+
- **Behaviour change (issue #23 — the permanent validation 500):** a vendor field-validation 500 whose `errors[]` text matches the documented heuristic phrases (`isDeterministicVendor500` — `exceeds maximum length` to start; only wording seen in real responses) makes exactly ONE upstream call (no retry), keeps the `ServerError` class + `SERVER_ERROR` code + status 500, but carries `retryable: false`, `details.deterministic: true`, and a deterministic `suggestedAction` naming `<resource>.getFieldInfo` (the field catalog with `maxLength`). Any other 5xx body is byte-identical to before (retried per policy, `retryable: true`).
|
|
502
|
+
- **New (issue #27):** the test-only fault-injection fixture `test/resources/fault-injection.ts` — `respondOn(method, pathSuffix, { status, contentType, body }, { times })` stacks raw-response rules over the existing fetch stub (registration order; a `times`-capped rule answers N times then falls through; `contentType: null`/`''` sends NO `Content-Type` header). `mockFetch` is untouched — existing tests keep their mocks (zero diff).
|
|
503
|
+
- **New (issues #11/#12): zero-wire planning-surface reads + the bounded reachability probe:** `operations.blindSurfaces` serves the 116-surface inventory (per-surface kind — `unowned-blind` / `unfiltered-blind` / `data-full` / `unknown` — the recorded row counts, the authority op, the zone note) and `operations.createGuidance` serves the zone facts per child (the create endpoint, parent scoping, the live-proven prefix, and the blocker — 92 create rows decoded during the stage-11 write campaign with verbatim vendor evidence; a 9th blocker kind `unknown` for the wall-adjacent rows) — both ZERO vendor calls (generated plan data, drift-guarded). `operations.probeReachability` (an `effect=read` op — route it through `autotask_read`) re-probes chosen surfaces LIVE: `confirmProbe: true` required, caps of 40 surfaces / 80 wire calls (over-cap REFUSES, never truncates), per-surface failures isolated (the sweep continues; failures logged with the machine code), `wireUsed` single-sourced, zero results honest. Live-verified on ww29 (5 surfaces / 5 reads: the blind walls re-confirmed, the W1 `ticket_history` wall isolated per-surface).
|
|
504
|
+
- **New (issue #9): delete-boundary awareness + the one-call recovery op:** the plan pins 40 spec-derived delete boundaries (vendor 405 — cleanup is UI-only) and 22 marker fields; `operations.recovery` finds the records a marker-tagged write created: `{ resource, marker, field?, parentId?, filter?, limit? (cap 25, max 100), action: 'find' | 'delete' }` — `find` is ONE bounded query (zero mutating wire), `delete` is destructive and dry-run-first (apply requires the governed `confirm === 'operations.recovery'`), one DELETE per safe match id + one verification read per id. Refusals are typed: `DELETE_BOUNDARY` (a spec-derived delete-less resource — refused PRE-SEARCH), `MARKER_UNSAFE_ID` (a marker that looks like an id), `RECOVERY_TRUNCATED` (the match cap tripped — never a silent truncation or silent empty). Live-verified on ww29: the full create → find → delete → NOT_FOUND cycle on `contact_groups` (the vendor's own field ladder absorbed: `groupName` rejected, `name` + `isActive` required).
|
|
505
|
+
- **New (issue #14): the typed empty reason on zero-row reads:** a 0-row `count()` on a plan-pinned surface returns `{ count: 0, emptyReason }` — the pinned typed reason (kind + note + authority + remedy, from the generated plan's `emptyReasons` table: 184 rows) instead of a bare zero — and the 5-op pointer set (the 3 known-unusable ops + the W-D3 `getPosted`/`getUnposted` pins) rides as `details.pointerNote` on the op's error (status/code untouched; the note routes to the operation that works). The v0.8.0 filter-aware `blindSurface` sentinel on count/query is unchanged. Live-verified on ww29 (`companies.count` 0 rows serves the typed blind-surface reason — the pin's note text names the `unowned-blind` kind; the pinned notes ride live vendor 500s).
|
|
506
|
+
- **New (issue #3): closed input on the entire MCP surface:** all 15 MCP tools' input schemas are closed (`additionalProperties: false`) — an unknown top-level key is refused PRE-REQUEST as a typed `CONFIG_ERROR` naming the key + the valid keys (never the value); the query-family params bags are closed the same way (valid keys named); positional-arity mismatches refuse (v0.2.0 silently dropped extra args). The governor's closed option bag (`dryRun` / `confirm` / `lastModifiedDate`) is unchanged.
|
|
507
|
+
- **New (issue #7): wire-level MCP surface probes + `npm run verify`:** `check-mcp-surface` now drives real `tools/call` DRY probes through the reference servers (the mode refusals, the confirmation gate, the typed unknown-op refusal — zero vendor wire) with an offline network-trap proof that DRY mode issues ZERO outbound requests, covers the HTTP server's SIGTERM clean-shutdown leg, and mirrors in CI as `npm run verify` (the full 9-step gate: typecheck, lint, build, `capabilities:check --ship`, `mcp:project:check`, `catalog:check`, `mcp:check-surface`, `public-surface`, `npm test`).
|
|
508
|
+
- **New (issue #2): the read-only doctor preflight + CLI:** `runDoctor(client)` (SDK, `src/doctor.ts`) — 9 checks (config, zone discovery with the documented mismatch warning naming the actual host, connectivity, five one-row entity reads, tenant threshold headroom), at most 8 vendor requests, retries OFF; `npx node-autotask doctor [--json] [--timeout-ms]` exits 0 (no failures) / 1 (a check failed) / 2 (missing or invalid config), fully redacted (never credentials, never vendor free text). Live-verified on ww29: 8 pass + the documented zone-mismatch warn, request budget exactly 8/8.
|
|
509
|
+
- **New (issue #1): untrusted-content markers on the served surface:** the plan pins `untrustedFields` per read op (83 affected resources / 849 registry records) and the projection wraps externally-sourced free text in `<untrusted_content>…</untrusted_content>` on the served surface (the SDK primitives stay raw); affected tool descriptions carry the `UNTRUSTED_NOTE` and the wire `outputSchema` the `x-untrusted` field-list extension. New zero-import `node-autotask/untrusted` subpath: `wrapUntrusted` / `markUntrusted` / `deepMarkUntrusted` / `stripUntrustedWrapper` (one outer layer) / `stripUntrustedDeep` (all layers, deep) the reference servers honour `AUTOTASK_UNTRUSTED_WRAP=off`. Live-verified on ww29 (the wrapping visible in wire outputs, e.g. `resources.whoAmI`'s `dateFormat`).
|
|
510
|
+
- **New (issues #4/#5): the agent config profile + the bounded ClientPool + gateway mode:** `profile: 'agent'` (conservative limits for LLM-driven workloads: 8,000 req/hour, page size 25 (capped 100 — an explicit higher value is clamped, not rejected), timeout 20 s, 1 retry, concurrency 3, 4 MiB responses, resolution scan 200 records / 2 pages; resolution order explicit config > profile > library default; `rateLimit: { perHour: null }` disables even under the profile) with env overrides in the reference servers (`AUTOTASK_PROFILE` / `AUTOTASK_RATE_LIMIT_PER_HOUR` / `AUTOTASK_MAX_CONCURRENT` / `AUTOTASK_TIMEOUT_MS` / `AUTOTASK_MAX_RETRIES` — an invalid value stops startup naming the variable). `createClientPool` (`node-autotask/pool`): a bounded LRU (default 128) of clients keyed by a sha256 digest of the credential tuple — one client per tenant, so limiter state and the read cache persist across a tenant's requests and never across tenants. The reference HTTP server's opt-in `AUTOTASK_GATEWAY_MODE=on` serves per-request credentials from headers (`x-autotask-integration-code` / `x-autotask-username` / `x-autotask-secret` + optional catalog zone / `*.autotask.net` base URL / positive-integer `x-autotask-impersonation-id`; empty or duplicate values rejected) — the process env credentials are IGNORED (a missing header is a 4xx naming it, never a silent env use). Live-verified on ww29: gateway mode with zero env credentials served an authenticated whoAmI over HTTP; the missing-secret path answered 401.
|
|
511
|
+
|
|
512
|
+
- **Wire change (published op — issue #10):** `time_entries.getPosted` / `time_entries.getUnposted` no longer filter `postingStatus` — a field the vendor's TimeEntry model does not have (the live tenant answers 500 "Unable to find postingStatus in the TimeEntry Entity"; the W-D3 adjudication supersedes the plan's G4-era picklist claim). The wire is now the specification's `postedDate` presence (`exist` / `notExist`) — correct by spec: zones that honour the published spec answer these filters. **W-D3 (live, ww29, 2026-10-04, re-verified):** this zone's TimeEntry entity exposes NO posted-state filter field at all (the vendor 500s `postingStatus`, `postedDate` and `status` alike — a live field-existence oracle); on such zones the 500 from `getPosted` / `getUnposted` is now the TYPED known wall — the pinned `pointerNote` (with the live evidence) rides the error on both the typed client and the served MCP payload (see the details pass-through below), and the working posted-state window read there is `postedWithin`. Signatures, limits (1–500, default 25), error codes and the `ticketID` narrowing are unchanged; no new error codes. New: `time_entries.postedWithin(from, to, opts?)` — a bounded read over an inclusive UTC `dateWorked` window (`from`/`to` are UTC `YYYY-MM-DD` date strings) returning `{ items, postedCount, unpostedCount, window }`, with optional `posted` (true = posted only, false = unposted only, both counted in one pass), `ticketID` and `resourceID` narrowing (limit 1–500, default 25). All three ops are reads: `dryRun: true` is refused like any other read. Plan rows, the MCP projection and the live-coverage quirk pin (the ops were previously sweep-skipped as a zone quirk) are corrected to match.
|
|
513
|
+
- **New (issue #13):** create-path response-shape-drift recovery. Where the plan pins a `responseDrift` marker field for a resource (`ticket_checklist_libraries` first — the W-A7 drift: the vendor answers 200 with a body that is not `OperationResultModel`), a create whose 2xx body fails the envelope check runs ONE bounded read-only query on the resource's own surface for the caller's marker (default: the `name`, else `title`, value in the request body; `markerField` / `markerToken` overridable; `recoverIdFromMarker: false` restores the old behaviour exactly). Scope note: the marker read is collection-level (one `contains` condition, no parent narrowing — this resource's query filter field set is not live-verifiable here), and it is the RECOVERY's one read — the pinned resource's own vendor create field ladder (a `checklistLibraryID` reference the caller must read first, live-decoded stage-11) is the CREATE's cost, not the recovery's. Exactly one numeric match → the normal success result with the additive `idFrom: 'marker-recovery'` + `shapeDrift` note naming the observed and specced shapes; zero matches or more than one (or a cap-tripped search) → the existing `INVALID_RESPONSE` with the drift detail and the failed search (candidate ids listed — never a guessed id). The fallback is read-only, bounded (limit 5), skipped entirely in a dry run, and the pin lives in the plan (`responseDrift` block, proof-labelled) so it can grow as other endpoints are found to drift.
|
|
514
|
+
- v0.3.0 — review-run fixes (additive, plus one deliberate tightening): **Behavior change:** `invoke`/`autotask_read`/`autotask_write`/`autotask_delete` now validate `args` against the record's `inputSchema` BEFORE any request — extra args, too few args and wrong scalar types (numeric-string ids still pass, as before) are refused with a shape-teaching `AutotaskConfigError` (v0.2.0 silently dropped extra args and could land short arg lists in the wrong positional slot); the confirm gate still fires first. Registry: every record now carries the full per-operation field table (`inputSchema`, `outputSchema`, `examples`, `permissions`, `pagination`, `resolution`, `retry`, `errors`, `compact`), emitted by the generator and described by `capabilities.schema.json`; `autotask_describe` serves them; the optional `opts` bag of 18 read helpers (time_entries windows, `companies.searchByDomain`, `tickets.summary/timeline/slaHealthCheck`) is declared so those calls stay valid, pinned by a registry-vs-method-arity test and a TypeScript-compiler test that checks every `inputSchema` (arity, required/optional, scalar type) against the real method signature — it caught and fixed `findBySerial`/`findByEmail`/`findByTicketNumber` (declared `string | number`, take `string`) and the undeclared `opts` on `operations.resolveAny`/`searchAcrossResources`. New: opt-in `cache: { ttlMs }` read-through cache for get-by-id and `resolve` (off by default; snapshot at fetch time, returns copies, bounded at 4096 entries, writes never cached); `client.batch(fn, items, { concurrency? })` bounded-parallel helper (default `maxConcurrent`); `./resources` and `./types` subpath exports; live scripts auto-load `ROOT/.env` (shell vars win, values never printed; `AUTOTASK_ENV_FILE` overrides the path). MCP: `mcp:project:check` and `catalog:check` are now rebuild-and-diff (a hand-edit of the manifest or catalog fails); META tool descriptions compute their 248/561/55/363 counts from the registry; served annotations add `idempotentHint` and set `openWorldHint` on every tool (all ops reach the external Autotask tenant); `@modelcontextprotocol/server` dev dependency bumped to ^2.2.0; the manifest naming label now reads entity-first. Fixed: the v0.2.0 `zone_information.get` registry drift (shipped tree failed its own Ship gate). Recorded, unchanged: the 15 `time_entries` helpers exceed the 2-4 helper soft cap (removal would break v0.x consumers; deprecation is a later decision), `maxConcurrent` stays 10 (vendor ceiling), `query`/`queryAll` naming stays vendor-faithful.
|
|
515
|
+
|
|
516
|
+
- v0.3.1 — transport hardening (additive): redirects are never followed (`redirect: 'manual'`; a 3xx throws `REDIRECT_BLOCKED`, no auth-header leak); retry waits are capped at 60 s and never outlast the whole-call deadline (an over-budget `Retry-After` throws the `RateLimitError` with `retryAfter` at once); `/query` and `/query/count` POST reads now retry via the new `RequestOptions.idempotent` flag while POST create / PATCH stay non-retried; new `RequestOptions.signal` (`AutotaskAbortError`, code `ABORTED`, cancels retry sleeps); `client.batch` gains `signal` and `settle: true`; `rateLimit.perHour` is now enforced (sliding window, bounded by the call deadline); `parseIdentifier` rejects values above `Number.MAX_SAFE_INTEGER`; a 2xx non-JSON body throws `INVALID_RESPONSE` (was `NETWORK_ERROR`); new `maxResponseBytes` option (default 64 MiB, `RESPONSE_TOO_LARGE`); HTTP 412 maps to `ConflictError`; timeout/network/unwrap errors carry `suggestedAction` and `operation`; `collectAll` accepts `maxRecords`; a real write clears the read cache
|
|
517
|
+
|
|
518
|
+
- v0.3.1 — QA-round governor, registry and surface fixes: **Behavior change:** `dryRun` must be a real boolean — a truthy string such as `'true'` used to fall through the methods' strict `=== true` check and issue a live write; `invoke` now refuses non-boolean `dryRun` before any request and only an explicit `false` applies. `createIfNotExists` takes an optional trailing `dedupFields` (string[]) through `invoke` (it was unreachable, so the call was a plain create); a `download` through `invoke` returns bounded base64 JSON (inline up to `INVOKE_MAX_INLINE_BYTES`, 1 MiB; metadata only above) instead of a raw Buffer. Read cache: the stale-object guard and dry-run diffs read the live record (`fresh`), never a cached snapshot, and any real write clears the cache. `operations.resolveAny` rethrows real faults (auth, rate limit, server, network, timeout, policy) instead of reporting them as misses and sets `scanTruncated` from `RESOLUTION_TRUNCATED`. `tickets.timeline`/`slaHealthCheck` report `truncated` (and `cappedSources`/`textTruncated`); `resources.transferOwnership` reports partial progress on a mid-run failure; `queryAll` accepts `maxTotalRecords`; `unwrap*` errors carry the operation. Registry (plan patch `scripts/g12-plan-patch.mjs`): ticket compositions document the numeric id they actually take and name their real return types, every row documents the transport error codes, `sensitive` is set on the webhook/contacts/resources ops, `pagination.mode` reads `cursor`, MCP descriptions of deletes say "Destructive and irreversible", and the projection no longer advertises `expand` (a no-op) or a `limit` the `findBy*` methods do not take. MCP example: `errorContent` serialises the full error contract (`code`, `category`, `retryable`, `httpStatus`, `operation`, `suggestedAction`, `correlationId`, `resourceIds`) — it read a non-existent `status` field. Cancellation: new `client.withSignal(signal, fn)` (an `AsyncLocalStorage` ambient signal) cancels every SDK request and retry wait started inside `fn`, so an MCP handler passes `extra.mcpReq.signal` through `invoke` with no per-method signature change (the example servers do); `client.batch({ signal })` now cancels in-flight items too. Gates: a CI workflow (typecheck, lint, build, ship/manifest/catalog/surface/public-surface checks, coverage) and tests for the manifest/catalog negative fixtures and the public-surface baseline
|
|
519
|
+
|
|
520
|
+
- Query wire body always carries a `filter` field (LIVE bug fix, node parity): the `buildQueryRequest` POST body now always sends `filter` (an empty array when the caller passes no conditions) — the node initializes `filters.filter = []` before EVERY query POST (get-many.ts, "Ensure filters.filter is initialized") but the SDK omitted the key. The AU03/ww29 zone API version NREs on the absent field (live-observed: `POST /V1.0/Tickets/query` with `{"MaxRecords":2}` → 500 `{"errors":["Value cannot be null. Parameter name: filters"]}`; with an explicit empty `filter` array → 200 (coordinator raw probe)). Two stale-shape pins updated (`http.test.ts`: absent MaxRecords/IncludeFields still omitted, `filter` now always `{ filter: [] }`; `resolve-semantics.test.ts`: the unfiltered client-scan pins `filter: []`). Live-verified: `npm run live-smoke` 6/6 PASS against ww29 (incl. `tickets.query` fetched=0 hasMore=false)
|
|
521
|
+
|
|
522
|
+
- `zoneInformation.get()` now sends the swagger-required `user` query param (LIVE bug fix): the vendor 404s the param-less call ("No action was found on the controller 'ZoneInformationApiIntegration'" — a Web API required-param miss, live-observed against the ww29 tenant). `get(user?)` defaults to the client's configured UserName (the endpoint's documented purpose — find the zone of the authenticating account; `HttpClient` gains a public-read `userName`, same precedent as `redactPersonalData`); an explicit arg queries another user's zone (URL-encoded). Live-verified: `GET /V1.0/ZoneInformation?user=<username>` → 200 `{ zoneName: 'AU03', url: 'https://webservices29.autotask.net/ATServicesRest/', webUrl: 'https://ww29.autotask.net/' }`. Tests: the pinned title test now asserts the default `?user=<configuredUserName>` wire + 2 new pins (explicit arg URL-encoded; blank arg falls back). The zone-location flow is documented: every zone host forwards to the user's real zone, so the recommended first call is `zoneInformation.get()` against any zone host
|
|
523
|
+
|
|
524
|
+
- Live-credential env vars renamed to the official Autotask API-User-Security terms (scripts/examples/docs only — the SDK config properties are unchanged): `AUTOTASK_API_CODE` → `AUTOTASK_TRACKING_IDENTIFIER` ("Tracking Identifier"), `AUTOTASK_USER` → `AUTOTASK_USERNAME` ("Username"); `AUTOTASK_SECRET` ("Password/Secret") and `AUTOTASK_ZONE` unchanged. The old names are deprecated but still accepted aliases (canonical wins when both are set — pinned by new tests); all 4 examples + both live scripts + `check-mcp-surface` (which now clears canonical AND alias names in no-credential child envs) + the docs carry the new canonical names. Related live finding fixed at the source: a double-quoted `.env` value containing `$` (the real secret) is shell-expanded by the documented load path (`set -a; . ./.env`) and silently mangles the credential (verified: 23 of 25 chars exported) — the `.env` template is now single-quoted with a warning comment, and the 401 triage row documents the trap
|
|
525
|
+
|
|
526
|
+
- The `autotask_invoke` catch-all is REPLACED by `autotask_read`/`autotask_write`/`autotask_delete` — the node enforces read/write/delete modes (the mode predicate is the registry `effect`, verified 1:1 over all 1167 ops: 921 read / 192 write / 54 destructive), cross-mode refusals PRE-REQUEST (zero wire — a write op on the read tool, a delete op on the read or write tool, or a read op on the write or delete tool refuses before holder/method resolution, and the message carries the op key + the effect + the correct target tool), dry-run-first + `confirm === <op key>` preserved (the 3 approval-gated writes keep their confirm gate INSIDE `autotask_write`; all 54 deletes require confirm in BOTH dry-run and apply mode). SDK: `invoke()` is bit-identical (the programmatic escape hatch — its mode is derived from the record, so it can never be cross-mode-refused), +3 additive exports `invokeRead`/`invokeWrite`/`invokeDelete` (+ type `InvokeMode`) over ONE shared router (`invokeMode`). MCP surface: `autotask_invoke` hard-removed (the package is unpublished; the only consumer is the owner's harness — migration documented), the 3 mode tools ride the META mechanism and ALL join R1 core (13 → 15 core — the split design doc's "16" miscounted the −1 of the invoke removal; the mechanism wins: 5 META + 10 registry-backed); post-split surface 809 = 804 dedicated (UNCHANGED) + 5 META. Annotations per tool (pinned by check-surface per-mode asserts): `autotask_read` readOnlyHint:true; `autotask_write` readOnlyHint:false + openWorld; `autotask_delete` readOnlyHint:false + destructiveHint:true + openWorld. The 363 invoke-only catalog rows (all effect=read: getEntityInfo/getFieldInfo/getUdfInfo ×121) update their reach text to `autotask_read`; the counts key `invokeOnly` is renamed `readModeOnly` (363 unchanged). Harness config migration (README + docs/API.md + the 3 tool descriptions): approve EVERY tool with `readOnlyHint !== true` — exactly 248 today (246 dedicated write/delete + `autotask_write` + `autotask_delete`); the 561 `readOnlyHint: true` tools are safe ungated (every op they route is effect=read — pinned by the machine-derived test + the dry-run suites); the 55 `destructiveHint: true` tools (54 dedicated + `autotask_delete`) require confirm. Evidence: NEW full-surface test `test/operations/invoke-modes.test.ts` (all 1167 ops × 3 modes, offline zero-fetch spy holder; RED at HEAD = the wrapper exports missing) + `test/scripts/invoke-split-surface.test.ts` (the readModeOnly key, the 363 reach texts, and the 248/561/55 policy counts MACHINE-DERIVED from the committed catalog vs README/API.md/the 3 descriptions); `test/operations/invoke.test.ts` passes UNCHANGED (the parity pin). Registry + plan-SSOT + MCP_TOOL_OVERRIDES.json + redaction UNTOUCHED (planHash 0e826d3ba248; the 5 protected files out of the diff); public surface: 0 removed (8 additive — 3 pre-existing + the B-round `redact.js` + 3 wrappers + the `InvokeMode` type); surface: −1 +3 (807 → 809)
|
|
527
|
+
|
|
528
|
+
- Response redaction (the INV-A1 secret-leak hotfix): the A1 audit (`.run/reports/leak-audit.md`, zero vendor calls, hudu code-found standard) confirmed the SDK response path was a verified pass-through — plaintext `password` / `secretKey` / `secret` values reached every caller (27 leaking read-op surfaces incl. `autotask_invoke`; the error path was a partial gap). The fix deep-redacts the parsed 2xx body at the single choke point (the 2xx branch of `HttpClient.request`, after `JSON.parse`) with the existing `'[REDACTED]'` marker: the **SECRET class** (pinned exact vendor casing: `password`, `secretKey`, `secret`) is **always** redacted with no opt-out (the SDK is the only permission boundary — the vendor API has no read-only key), and the **PII + CONFIDENTIAL classes** (53 pinned names from the A1 artifacts: 41 personal + 12 business/device) are redacted by default, opt-out via the new `redactPersonalData` config (default `true` — the opt-out disables ONLY personal-data redaction; credentials are never opted out). `authToken` (token issuance, `GET /V1.0/Authenticate`) is exempt and never redacted (documented + pinned by test); request bodies are never redacted (credentials travel in writes by design — the G10b request-body-audit redaction is untouched); redaction is independent of `includeFields` (a selected secret field comes back `'[REDACTED]'`, the safe behavior). Second site: `errorFromStatus` deep-redacts the vendor 4xx/5xx body once, so `vendorError` and the adopted message never carry a pinned field value (a 4xx echoing a submitted secret no longer leaks through the error path; `errorFromStatus` gains a trailing optional `redactPersonalData` param, additive). A scripted swagger pin test (`test/redact-swagger-pin.test.ts`) re-scans `api-docs.json` every run and fails on vendor-model drift (SECRET census 3/15/6 = 24 hits, the 53 pinned casings, the `authToken` exemption). Behavioral note: internal response-value consumers see the redacted values under the default policy (`contacts.moveToCompany`'s duplicate-email precheck filters on `'[REDACTED]'` — the vendor-side constraint remains the backstop); set `redactPersonalData: false` in regulated workflows that need raw PII. Registry + plan-SSOT untouched (planHash 0e826d3ba248); full 10-command gate green (typecheck / 2017 tests / build / plan:derive 0-corrected / capabilities:check 0-stale / mcp:project:check 804/core 13 / catalog:check 1157 / mcp:check-surface stdio+http PASS / public-surface 0 removals (+1 additive: root-star redact.js) / zero runtime deps)
|
|
529
|
+
|
|
530
|
+
- Live-readiness minors close-out (LIVE-4): the five MINOR findings of the adversarial review are closed (none changed wire behaviour): M1 — the 54 destructive `delete` dry-runs now route through `operations.invoke` with `confirm === <op key>` (the governor's deliberate-act gate runs before any wire activity, matching the 3 other gated kinds; the F8 envelope check is preserved — a malformed envelope is still `error:DRYRUN_ENVELOPE`); the per-row dispatcher moved to a testable `scripts/lib/live-coverage-run.mjs` with an offline fake-holder proof (`test/scripts/live-coverage-run.test.ts`), and the three text sites that overstated confirm coverage are corrected; M2 — both budgets are now stated at every claim site: PLAN-HONEST <= 1119 (921 + <=77 + <=121) and WIRE-HONEST <= 1265 raw wire requests (the <=146 dry-run verification reads that ride on top: 54 delete current-record GETs + 63 scoped-create parent-verify GETs + 20 getContext part GETs + <=9 special-op reads — machine-derived in `wireVerificationReads`, pinned in the report's `budget` block); M3 — live-run PII hygiene: the console prints row id + classification only (tenant-text search slices stay in the local report file), and runbook §7 gains the explicit POST-RUN step (restore the committed DRY baselines, or the recommended gitignored `--report .run/reports-live/`); M4 — the G10b cleartext-`http:` baseUrl warning is now user-visible: both live scripts (and both MCP example servers) pass a console `logger` (`warn` -> `console.error`); the NoopLogger default's swallow is pinned by unit tests and the stub e2e asserts the warning appears; M5 — the "exactly 5" smoke wording is corrected at every text site: the smoke is 5 vendor probes = 5 wire calls on a >=2-record table and 6 worst case (a below-limit 1st page with a live cursor fetches a 2nd, empty page — `queryBounded` stop condition: limit-met OR empty-page OR cursor-null), pinned by a new 1-record-table stub variant asserting exactly 6. Registry untouched (planHash 0e826d3ba248; the 5 protected files out of the diff); full 10-command gate green (typecheck / 1994 tests / build / plan:derive 0-corrected / capabilities:check / mcp:project:check / catalog:check / mcp:check-surface stdio+http / public-surface / zero-dep)
|
|
531
|
+
|
|
532
|
+
- Live-readiness hardening (LIVE-2): the live smoke/coverage scripts are honest about their call budgets and fail closed without credentials — every sweep/smoke read is bounded by a TOTAL cap (`queryBounded`; `maxRecords` is a wire page size, not a total cap): the smoke is exactly 5 vendor calls (4 GET + 1 bounded query) + a zero-network shape guard, the full sweep <= 1119 vendor calls worst case (921 bounded op calls + <=77 update diff GETs + <=121 cached limit-1 id reads); missing or PARTIAL credentials HARD FAIL exit 2 naming the exact missing env vars (the `--dry` flag is the only no-credentials path, DRY-RUN-ONLY verdict — the silent green false-pass is gone); the 8 scoped createIfNotExists rows carry the parentId (G10-F1 contract) with empty-parent -> skipped:no-data (no crash); search/resolve rows make real calls (text/id from the bounded read); the 57 destructive/approval-gated ops get `confirm === <op key>` (dry-run writes stay zero-mutating; classification renamed verified-live/verified-dry, the dead skipped:read-only-refused branch removed, a malformed envelope -> error:DRYRUN_ENVELOPE); the unknown-zone error lists the 23 catalog zones and the scripts echo `zone -> <base URL>` at live start; the dist-freshness guard now covers BOTH scripts; `tsx` is a declared devDependency (the README's `npx tsx` is honest); the README quick-start is bounded (`queryBounded` + includeFields) with the true guard budgets; new `docs/LIVE-TESTING.md` runbook (true budgets, the MCP-live option, failure triage) and stub-server end-to-end proof (`test/scripts/live-smoke-stub.test.ts` asserts the exact 5-call budget against a local http server; the offline 1167-row planning proof pins parentId/confirm/call-plan/budget with no network)
|
|
533
|
+
|
|
534
|
+
- includeFields context-saving guidance on the MCP query surface: every `query` registry record's `metadata.args` now ends with the lever sentence (the vendor returns the FULL model by default; pass `includeFields` with exact vendor field names — via `<entity>.getFieldInfo` — to slim the payload; the subset persists across the internal page walk), the `query` `preferredWhen` steering gained "Prefer an includeFields subset over the full model", all 124 `get` registry records gained `metadata.args` (endpoint-driven arg shape — top-level `id`, scoped `(parentId, id)`, or no-arg global reads — plus the full-model note: the vendor documents no field selection on single-record reads; slim-record pointers to `search`/`query` only where the op exists), the 116 `count` records' arg list corrected to the filter-only wire (the count endpoint takes no MaxRecords/IncludeFields — the old misleading listing is gone), and the manifest's `includeFields` input-schema field gained its describe ("field subset to return (vendor API casing; omit = full model — pass to save context)"). The manifest/catalog required-args classifier for `get` rows is now endpoint-driven, fixing seven parentScoped-flagged resources with top-level GET endpoints (`expense_items`, `quote_locations`, + four webhook entities) that were mislabeled `[parentId, id]`
|
|
535
|
+
- Server-side pagination for the MCP query surface + filter-grammar guidance on the describe/invoke surface: new `queryBounded` base-class primitive (one call walks the vendor cursor internally until `limit` TOTAL records — default 500, max 2500 — or the cursor is exhausted; 1000-page guard; honest `hasMore`; `pageDetails.count` surfaced never, since the vendor documents it as the current page's record count) — the `autotask_invoke` governor now routes registry `query` ops to it (one tools/call = one bounded result; the LM passes `limit`, never owns a stream; `queryAll` keeps its raw behavior with catalog steering), and every `query`/`count` registry record carries `metadata.args` (exact arg shape + the 13 filter operators + and/or + vendor-casing `getFieldInfo` pointer + caps + `search` preference) that `autotask_describe` returns verbatim; query purposes now state the bounded semantics (plan-SSOT, inherited by the 116 manifest query tools' input schemas: `filter` / `limit` (500/2500) / `includeFields`)
|
|
536
|
+
- node-autotask SDK for the Autotask API — 121-entity MVP set per the n8n node contract, plus 7 integration endpoints
|
|
537
|
+
- Zero runtime dependencies; dual ESM + CJS output
|
|
538
|
+
- Capability registry (1167 records, hand-authored plan as SSOT) and generated MCP projection (804 tools: core tier 13 by CORE_RULE + 1157-row catalog meta-tool)
|
|
539
|
+
- 3 node-parity ops closing the node-vs-SDK coverage deltas — `configuration_item_ssl_subject_alternative_names.update` (scoped-collection PATCH; the node strips the id from the update path, the record is keyed by the body `id`), `contact_group_contacts.create` (top-level POST) and `contact_group_contacts.delete` (top-level DELETE): all node-proven and UNVERIFIED-vendor (the vendored swagger documents no create/update/delete verb at the pinned paths); dry-run-first, destructive delete exact-confirm gated; registry 1164→1167, MCP tools 801→804, catalog 1154→1157 (public-surface +3 additive)
|
|
540
|
+
- MCP disambiguation for the easily-confused task/checklist entities: plan-SSOT `purpose` text for all ops of `tasks`, `checklist_libraries`, `checklist_library_checklist_items`, `ticket_checklist_items`, `ticket_checklist_libraries` (Tasks = task/project-module work items, NOT checklist steps and NOT ToDos; ticket checklist steps vs. reusable library steps vs. the library container vs. the library↔ticket link), inherited verbatim by the MCP tool descriptions and the catalog purpose column
|
|
541
|
+
- Agent execution layer — resolve/search/findBy/getContext floor, `./operations` searchAcrossResources/resolveAny/invoke write governor
|
|
542
|
+
- Mutation safety — dry-run envelope on every mutation, stale-object guard, classification, audit with redaction
|
|
543
|
+
- Examples — basic, stdio + HTTP MCP servers
|
|
544
|
+
- Scripts — derive/generate/check, live-smoke/live-coverage (dry), mcp:project, catalog, public-surface, docs:render
|
|
545
|
+
- Docs — API.md, N8N-ADAPTER.md thin-importer proof with generated op-mapping + 55-row swagger-gap table; the adapter table's node-ops column is re-derived from the node source at render time (the `name: 'operation'` property options only — the timeEntries dateRange presets are filter options, not ops; the 13 phantom ops in the timeEntries row are gone and it shows exactly the 9 real ops), with an explicit `resourceTimeOffAdditional` `get → getByResource` mapping; the 3 former "non-goal, documented not built" ops are documented as built as node-parity (UNVERIFIED-vendor); API.md gains the Tasks QoL note (the `Projects/{id}/Tasks` / `ServiceCalls/{id}/Tasks` scoped mirrors are expressible as `tasks.query` with `projectId` / `serviceCallId` filters; `CompanyToDos` (+ `Companies/{id}/ToDos`), `taskNotes`, `taskPredecessors`, `taskAttachments`, `taskNoteAttachments`, `deletedTaskActivityLogs` are vendor-present, node-absent, v0.2 candidates)
|
|
546
|
+
|
|
547
|
+
- requiredArgs classifier fix + article polish: the MCP surface's argument lists now match the ACTUAL generated method signatures across the full surface — a shared classifier (`scripts/lib/args-derive.mjs`, wired into BOTH the manifest and catalog generators; endpoint-driven primitive rules + a signature-audited per-op special-op arg map with a test tripwire that re-parses every signature) corrects 39 rows the parentScoped resource flag or the endpoint template mislabeled (17 webhook/quote-location CRUD rows claimed `[parentId, …]` where the methods are top-level, 8 scoped `createIfNotExists` rows — the catalog said `[data]` where the base.ts unified overload requires `(parentId, data)` —, 4 special-op rows missing required args (`getByResource` ×2, `getByYear`, `moveToCompany`), 1 `documentId`/`documentID` label casing, and 9 rows the scoped-only audit never reached: `transferOwnership`, `approve`, `reject`, `searchByDomain`, `searchByIdentity`, `moveConfigurationItem`, `slaHealthCheck`, `summary`, `timeline`) — the manifest and the catalog now agree on requiredArgs for all 804 tools and every row was verified against the parsed signature (1167-row full-surface check, 0 mismatches). The S1b article nit is fixed at the plan level by the audited idempotent `scripts/g11g-plan-patch.mjs`: 11 a/an corrections templated from the leading letter (9 vowel-leading get pointers "a expense_items.query" → "an …", 2 `createIfNotExists` purposes "Create a ExpenseItem/Opportunity" → "Create an …"); "a one-call" and "an SSL" verified correct (no change).
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
### Known limitations
|
|
551
|
+
|
|
552
|
+
- Live API behaviour UNVERIFIED-live this run (no credentials; committed dry evidence in `.run/reports/live-*.json`)
|
|
553
|
+
- 55-row swagger-gap table — node-proven / vendor-tolerated semantics (incl. the 3 new node-parity rows)
|
|
554
|
+
- MCP protocol conformance pinned to the installed @modelcontextprotocol/server 2.x era
|
|
555
|
+
- v0.4.0 — MCP-substrate hardening + the confirmed-update routing fix (night mission 2026-10-03): **Behavior change (P0, systemic):** confirmed (non-dry-run) updates on the 40 collection-only-writable top-level resources (tickets, companies, resources, projects, time_entries, configuration_items, …) now route to the spec-correct collection path — `PUT /V1.0/<E>` with the id merged into the body — instead of `PUT /V1.0/<E>/{id}`, which the vendor 405s (the vendored spec defines no put/patch on any top-level item path; dry-run had masked the defect: it is a GET-item precheck + local diff, which is unchanged and keeps the `STALE_OBJECT` guard and the F8 simulated envelope — the dry envelope now reports the confirmed route it would take). Special ops that update on the item path (resources.transferOwnership, the moveConfigurationItem source deactivation) move to the collection route; the flag set is generator-derived from the spec with a drift-guard test (flag set == spec). **Behavior change:** `configuration_items.moveConfigurationItem` declares `destinationCompanyId` in its inputSchema and the invoke dispatch threads it (previously every invoke/`autotask_write` call threw `CONFIG_ERROR` pre-wire). **MCP surface:** the 2026-07-28 stateless protocol surface is asserted by `mcp:check-surface` (stateless, no session header, `server/discover` via the SDK, `tools/list` cacheHints `ttlMs: 300000` + `public`, deterministic ordering) and the reference servers honour `AUTOTASK_BASE_URL`; both reference servers wire the SDK `onAudit` hook to a shared redacted stderr sink (one structured line per settled request — correlation id, op key, registry effect, status, latency, body length only; never the MCP channel; DRY mode stays wire-free and audit-free). **n8n consumer projection:** `scripts/project-n8n-mcp.mjs` builds the checked-in `N8N_MCP_MANIFEST.json/.md` from the registry (14 tools: 7 read + 7 write, effect-split, annotations never null, `dry_run` default-true + `confirm` on every write tool, the 3 destructive ops excluded by default but honestly listed with their confirm rule) + `n8n-mcp.curation.json` (written inclusion rule, fail-closed) + `n8n:project:check` drift mode. **Sweep:** the live-coverage sweep classifies `ticket_checklist_libraries.attachToDocument` as `skipped:no-data` (the Document id domain is outside the registry — the pre-wire arg validation now refuses the mis-shaped invoke pre-wire); the full 1167-op sweep runs 0 error (715 verified-live / 83 verified-dry / 369 skipped, zero mutating wire). **Generator:** emits the `redactPersonalData` policy arg on dry envelopes and the `collectionUpdate` flag; the hand-added `companies.queryBounded` tests live in a generator-proof sidecar file. Verified: 2218/2218 tests, 10/10 gate battery, live r/w battery across the 10 most common endpoints (zero applied mutation outside the authorized probes; full create→delete scratch cycle needs a tenant with a company + a non-API-only resource — dry-run + error-contract + cross-mode paths verified on all 10).
|
|
556
|
+
- v0.5.0 — allocation-code-aware time entry create (the node does the heavy lifting): new `client.timeEntries.createForResource(resourceID, params, opts)` (+ registry op `time_entries.createForResource`, exposed on the MCP surface and the n8n projection) resolves the vendor's live time-entry gates that were previously the caller's burden: exactly one linkage (DEFAULT internal — a time entry linked straight to an existing allocation code, no ticket/task needed; caller pins `internallAllocationCodeID`/`ticketID`/`taskID`/`roleID` with internal>ticket>task precedence, >1 pin refused pre-wire) and an EXISTING active allocation code APPLICABLE to the resource (department-scoped: tenant-wide codes or the resource's active role-departments; deterministic tie-break Normal-first then id-asc; the node NEVER creates codes — the API user has no such permission, and a `NO_APPLICABLE_ALLOCATION_CODE` typed failure carries the exact filters tried + the resource's department set as the tenant-admin action). Pins are verified, not substituted (typed `ALLOCATION_CODE_NOT_FOUND` / `_INACTIVE` / `_WRONG_TYPE` / `_NOT_APPLICABLE` / `RESOURCE_INVALID`). Task/ticket linkage resolves `roleID` as an allocation code through the H1 demotion ladder (live-adjudicated: the vendor double-validates `roleID` — an active general allocation code AND a Role reference; where the sets are disjoint, the sanctioned task->internal fallback is the working route); the POST body key is the model name `internalBillingCodeID` (the gate-1 error-text spelling is not deserialized — live-probed). Dry-run runs every resolver read and simulates the envelope with the exact apply body (F8); the raw `time_entries.create` is unchanged underneath. The resolver always queries `billing_codes` WITH a filter — the zone's unfiltered BillingCodes count/query surface is blind (returns 0). Read-only fields (`hoursToBill`, `timeEntryType`) are never sent; a <=3-merge discovery loop rides the create budget on 500 field-validation. Verified: 2237/2237 tests (19 new mock-fetch), 10/10 gate battery, live sandbox proof — the resolver auto-picked the applicable "Internal Meeting" code for a real employee fixture, the entry was created, verified (marker + code match) and deleted (zero residue).
|
|
557
|
+
- v0.6.0 — partial-update merge (V2 full-model fix) + the delete-surface drift guard:
|
|
558
|
+
- `update(id, data, opts)` on the 40 top-level `collectionUpdate` resources now MERGES the caller's partial body into the record's current full model before the collection PUT — the vendor validates the FULL model (a partial body 500s 'Missing Required Field'); pinned semantics: explicit `null` sends null, `undefined`/omitted keeps the current value, defined keys override wholesale; ONE fresh read serves the diff, the stale-object guard and the merge. `opts.raw: true` is the compatibility escape hatch (the exact pre-change wire, no merge read); dry-run envelopes now carry the MERGED body (zero mutating wire; previews stay redacted). Scope is build-time-derived from the spec via the shared `scripts/lib/collection-update.mjs` lib (drives both the generator and the registry — code and registry cannot drift; no hardcoded list). A redaction-choke bug caught by the new tests (the merge read would have written literal `[REDACTED]` markers into live records) is fixed via an internal-only `skipResponseRedaction` transport flag on the merge round-trip read only. Live-proven: a one-field partial update on a scratch project returned 200 with zero drift across all 44 fields.
|
|
559
|
+
- Delete-surface findings pinned: the vendored spec defines NO entity DELETE for the 7 common endpoints lacking delete ops (tickets, companies, resources, ticket_notes, configuration_items, projects, tasks) — live vendor probes returned 405 (method-level refusal), so the gap is vendor reality, not an SDK omission; the decision + evidence are pinned in the plan notes and a new drift-guard test (`test/scripts/delete-drift-guard.test.ts`) asserts spec DELETE paths == registry delete ops (pinned node-verified exceptions exact) so the gap can never silently re-open.
|
|
560
|
+
- The full 1,168-op baseline sweep at v0.5.0 landed clean (715 live / 83 dry / 370 skipped / 0 error); the new `createForResource` row is classified no-data on tenants with a blind Resources table (the sweep-side pin, `69d22c1`).
|
|
561
|
+
- v0.7.0 — MCP upstream fitness: LLM-facing guidance on the served surface + Tier-2/3 endpoints proven to Tier-1 grade:
|
|
562
|
+
- Guidance is now reachable at RUNTIME on the 15-tool MCP surface (the `autotask_describe` record channel, the same mechanism as the v0.6.0 merge stamp): the vendor-405 delete boundary ("No delete: the vendor defines no DELETE for this entity — cleanup is UI-only") on the 13 gap-endpoint create/update records, spec-derived via `scripts/lib/delete-boundary.mjs` (never a hardcoded list; double-render-guarded against the projection's short form); blind-surface zone notes on companies.count/query AND tickets.count/query (both return 0/empty in the australia-nz zone — `get(id)` is authority) and the always-filtered note on billing_codes.count/query (the unfiltered surface is blind); the silent-no-op warning on companies.create/update (CompanyModel has no `description` field — the vendor 200s unknown fields and ignores them; verify with `get`); live-discovered required sets on ticket_notes/tasks creates (incl. the `publish` int-picklist zone quirk — not a boolean) and the productID FK wall on configuration_items.create (unavailable in tenants without products; `products.count` blind). Docs gain the zone-quirks section (docs/API.md §11) and a no-leak regression pin (no internal transport flag or redaction marker in any served surface).
|
|
563
|
+
- The entire Tier-2/3 surface was E2E-proven on this build in the ww29 sandbox (marker-tagged, zero-residue-after-restore): projects/companies/tickets full applied lifecycles with the v0.6.0 merge path machine-asserted (GET item + PUT collection, zero item-path PUTs); ticket_notes and tasks full applied lifecycles under real parents; resources settled at the vendor's method-level 405 for creation (read-verified; the API user's own record is never touched); the delete steps of all seven gap endpoints re-proven 405 byte-identical. Full 1,168-op sweep after the changes: 715 live / 83 dry / 370 skipped / 0 error — identical to the v0.6.0 baseline.
|
|
564
|
+
- Test suite 2282 (28-test MCP-surface-guidance suite with spec-derived set-equality pins).
|
|
565
|
+
|
|
566
|
+
- v0.8.0 — the mature MCP-server base: common task/operations surfaced on top of the SDK, so an Autotask MCP-server builder needs no Autotask-relationship knowledge and few tool calls (1177-op registry / 814 manifest tools; the 15-tool core surface is unchanged):
|
|
567
|
+
- **4 write compositions** (one call where the raw API needs several): `tickets.openWithNoteAndAssign` (create + scoped note + assignment merge; a partial failure lands in the typed `steps[]` — the applied ticketId is still returned; live-verified vendor rules absorbed: the note requires a `title`, the assignment requires BOTH `assignedResourceID` + `assignedResourceRoleID`); `tickets.changeStatus` (43-value picklist guard + the per-status required-field discovery map; typed `STATUS_UNKNOWN` / `NO_CHANGE` / `STATUS_FIELDS_REQUIRED`); `contacts.upsertByEmail` (natural-key dedup under the parent company; the zone `isActive` quirk absorbed — a boolean maps to the zone-correct number; empty `lastName` defaults to `Unknown`; typed `EXISTS` / `CREATED` + `COMPANY_NOT_FOUND`); `companies.upsertByName` (blind-surface-aware dedup — a query-miss is not proof of absence, the caveat rides in the typed result on BOTH outcomes; `OWNER_RESOURCE_REQUIRED` with the fixture-free remedy; the V5 vendor auto-location side effect is reported).
|
|
568
|
+
- **5 read compositions** (N gets → 1 call): `tickets.fullContext` (9 wire → 1; typed per-part degradation), `companies.fullProfile` (typed blind degradation; the open-tickets part is always filtered, so a filtered empty answer is a TRUE zero — never a false blind sentinel), `configuration_items.profile` (the product-wall part is typed, not a crash), `operations.fetchMany` (up to 20 items across mixed resources in ONE call; client-side field projection; per-item typed `NOT_FOUND` / `SERVER_ERROR`, never all-or-nothing; pre-request refusals with zero wire: a string id points at `autotask_resolve_any`, >20 items, parent-scoped children), `operations.relationshipMap` (the full relationship surface of one resource in a ZERO-wire call, from the generated artifacts).
|
|
569
|
+
- **Blind-surface sentinel v2**: the blind count/query surfaces (companies, tickets, billing_codes) stamp their results with the typed `blindSurface { surface, zone, kind, authority, note }` — `count`/`items` are NEVER mutated (sweep-classification-neutral; the sentinel survives redaction, audit and the governor), so a 0 answer carries its own meaning and the verification pointer. One filter-aware predicate (`blindStampFor` in `src/resources/base.ts`) gates the query wrap, the count wrap AND `companies.fullProfile` — no consumer can drift. Kinds: `unowned-blind` (companies — every empty result is stamped; unowned companies are invisible regardless of filter) vs `unfiltered-blind` (tickets / billing_codes — ONLY the unfiltered call is stamped; a filtered empty is a true absence — live-proven: a filtered tickets query returns data on ww29 while the unfiltered surface stays blind).
|
|
570
|
+
- **Honest resolve contract + typed miss** (E2E-exposed): the five R4 core resolve tools now state the per-resource primary text field(s) they actually try (in the plan purposes, the curation overrides, the generated `inputSchema` and the live zod mirror) and a string miss returns `miss: { triedFields, note }` — which fields were tried and where to go next for other identifier forms (e.g. `contacts.findByEmail` via `autotask_catalog`). `Resolution` gains the additive `triedFields` on both string-miss paths; a miss is never a bare zero.
|
|
571
|
+
- **Served-guidance hygiene**: real-shape `exampleOverride` examples on all nine composition ops (fixture ids, the zone label, the dry-run bag on writes — no synthetic placeholders); required-args text corrected on `openWithNoteAndAssign`; campaign/run-process tags scrubbed from the entire served surface (record metadata AND the blind-surface notes), machine-scanned.
|
|
572
|
+
- **Verified end-to-end on the ww29 sandbox**: the 1177-row reconciliation sweep (0 error — 717 verified-live / 84 verified-dry / 376 skipped); the stranger-in-the-dark E2E (a fresh agent with ONLY the served surface: 9/10 tasks, zero internals leaked, zero untyped errors, 35/35 wire — the one miss was the stranger's own keyword search missing the served `fetchMany` row); the deterministic destructive E2E over the served surface (the vendor-allowed contact delete APPLIED + verified gone; the four documented vendor delete walls refused pre-request with typed errors, 0 wire; the auto-location delete surfaced a NEW vendor finding — the association guard: a location with associated tickets is API-undeletable (typed 500 with correlationId), so it joins the UI-only residue class).
|
|
573
|
+
- Generated artifacts: `src/relationships.ts` (128 resources) and `src/field-sets.ts` (117 entries) are both drift-guarded by `capabilities:check`; the 9 new ops ride the mode tools (`autotask_read` / `autotask_write`) and the n8n projection (81 read + 21 write ops) by registry projection — the SDK remains the canonical surface, MCP-import-free and zod-free in `src/`.
|
|
574
|
+
|