node-autotask 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/MCP_TOOL_CATALOG.json +156 -1253
  3. package/MCP_TOOL_MANIFEST.md +154 -220
  4. package/MCP_TOOL_OVERRIDES.json +8 -62
  5. package/N8N_MCP_MANIFEST.json +128 -705
  6. package/N8N_MCP_MANIFEST.md +43 -63
  7. package/README.md +10 -28
  8. package/capabilities.json +15315 -22219
  9. package/dist/{base-CVx2VMSj.d.cts → base-BVoYDlVA.d.cts} +2 -1
  10. package/dist/{base-CVx2VMSj.d.ts → base-BVoYDlVA.d.ts} +2 -1
  11. package/dist/capabilities.cjs +2 -2
  12. package/dist/capabilities.d.cts +2 -1
  13. package/dist/capabilities.d.ts +2 -1
  14. package/dist/capabilities.js +1 -1
  15. package/dist/{chunk-5VHSLJMR.js → chunk-A6LHCJQP.js} +30 -9
  16. package/dist/{chunk-JY2BWYXY.js → chunk-ASMCTZO2.js} +15314 -22218
  17. package/dist/{chunk-TH7B5H4P.cjs → chunk-DC3PY4EF.cjs} +1839 -1407
  18. package/dist/{chunk-4EFXIE53.js → chunk-DXKXQHCX.js} +5 -5
  19. package/dist/{chunk-SK3GYRNA.cjs → chunk-JO6WI763.cjs} +143 -143
  20. package/dist/{chunk-Q23JULNN.js → chunk-NSW7JPVS.js} +11529 -13319
  21. package/dist/{chunk-KTEDPS4X.js → chunk-O4PIPUS4.js} +1583 -1151
  22. package/dist/{chunk-MJTKK3QG.cjs → chunk-SN4BMQ2N.cjs} +31 -10
  23. package/dist/{chunk-5BDUMDAQ.cjs → chunk-VXVYFJH3.cjs} +15314 -22218
  24. package/dist/{chunk-VFLAMMIJ.cjs → chunk-YMBVKVK4.cjs} +10604 -12394
  25. package/dist/{index-C7rMJ4IV.d.ts → index-C5g1H_hF.d.ts} +1080 -1288
  26. package/dist/{index-Cm_Xpt7X.d.cts → index-Db0SNnQn.d.cts} +1080 -1288
  27. package/dist/{index-C3KFMnRT.d.cts → index-c549LvOb.d.cts} +332 -331
  28. package/dist/{index-C3KFMnRT.d.ts → index-c549LvOb.d.ts} +332 -331
  29. package/dist/index.cjs +23 -23
  30. package/dist/index.d.cts +15 -41
  31. package/dist/index.d.ts +15 -41
  32. package/dist/index.js +8 -8
  33. package/dist/operations/index.cjs +5 -2234
  34. package/dist/operations/index.d.cts +51 -323
  35. package/dist/operations/index.d.ts +51 -323
  36. package/dist/operations/index.js +30 -2259
  37. package/dist/pool.cjs +9 -9
  38. package/dist/pool.d.cts +4 -3
  39. package/dist/pool.d.ts +4 -3
  40. package/dist/pool.js +5 -5
  41. package/dist/resources/index.cjs +5 -5
  42. package/dist/resources/index.d.cts +2 -2
  43. package/dist/resources/index.d.ts +2 -2
  44. package/dist/resources/index.js +8 -8
  45. package/dist/types/index.d.cts +1 -1
  46. package/dist/types/index.d.ts +1 -1
  47. package/dist/untrusted.cjs +2 -2
  48. package/dist/untrusted.d.cts +14 -5
  49. package/dist/untrusted.d.ts +14 -5
  50. package/dist/untrusted.js +1 -1
  51. package/docs/API.md +321 -334
  52. package/docs/LIVE-TESTING.md +4 -6
  53. package/docs/N8N-ADAPTER.md +26 -26
  54. package/examples/mcp/schemas.ts +2 -0
  55. package/examples/mcp/tools-read.ts +3 -2
  56. package/n8n-mcp.curation.json +3 -3
  57. package/package.json +10 -9
package/docs/API.md CHANGED
@@ -260,9 +260,9 @@ status → what the vendor does that triggers it.
260
260
  | `QueryTooLongError` | `QUERY_TOO_LONG` | policy | — | caller-requested `via: 'get'` whose serialized URL would exceed the 2048-char GET cap — the SDK never silently degrades to POST |
261
261
  | `StaleObjectError` | `STALE_OBJECT` | conflict | — | the caller-supplied `lastModifiedDate` no longer matches the vendor's current value (no `If-Match`/ETag exists) |
262
262
  | `ConfirmationRequiredError` | `CONFIRMATION_REQUIRED` | policy | — | a destructive / approval-gated op invoked without its exact confirmation flag (`invoke` governor, section 7) |
263
- | `DeleteBoundaryError` | `DELETE_BOUNDARY` | policy | — | `operations.recovery` `action: 'delete'` on a resource the vendored spec defines no DELETE for — refused PRE-SEARCH (zero wire; the boundary is the spec-derived plan audit, never a raw vendor 405; issue #9) |
264
- | `MarkerUnsafeIdError` | `MARKER_UNSAFE_ID` | policy | — | a `operations.recovery` match whose id is beyond `Number.MAX_SAFE_INTEGER` (unaddressable through the typed delete surface) — refused with the typed code, never a silent skip (issue #9, P1) |
265
- | `RecoveryTruncatedError` | `RECOVERY_TRUNCATED` | policy | — | the `operations.recovery` marker search tripped its match cap (more matches than `limit`) — refused instead of a silent truncation / silent empty (issue #9) |
263
+ | `DeleteBoundaryError` | `DELETE_BOUNDARY` | policy | — | a delete on a resource the vendored spec defines no DELETE for — refused PRE-SEARCH (zero wire; the boundary is the spec-derived plan audit, never a raw vendor 405; issue #9). The throwing op (`operations.recovery`) was deleted in #289 — the refusal code stays in the write-outcome taxonomy |
264
+ | `MarkerUnsafeIdError` | `MARKER_UNSAFE_ID` | policy | — | a marker-filtered match whose id is beyond `Number.MAX_SAFE_INTEGER` (unaddressable through the typed delete surface) — refused with the typed code, never a silent skip (issue #9, P1; the throwing op was deleted in #289 — the refusal code stays) |
265
+ | `RecoveryTruncatedError` | `RECOVERY_TRUNCATED` | policy | — | the marker-filtered bounded search tripped its match cap (more matches than `limit`) — refused instead of a silent truncation / silent empty (issue #9; the throwing op was deleted in #289 — the refusal code stays) |
266
266
  | `AutotaskError` | `BUDGET_EXCEEDED` | policy | — (the call that would exceed it is not sent) | `operations.revenueRollup`: the pre-walk estimate exceeds `maxVendorCalls` (refused before any stream; `details: { phase: 'estimate', estimate, budget, suggestions }`) or the counting wrapper reached the budget mid-walk (`phase: 'walk'`; the partial fold is discarded) — `suggestedAction` names the narrowing (issue #234) |
267
267
 
268
268
  Retryable by contract: `NETWORK_ERROR`, `TIMEOUT`, `RATE_LIMITED`,
@@ -287,8 +287,7 @@ retryability: `emptyReason` (the pinned typed empty-reason — the miss's
287
287
  kind + note + authority + remedy; §11.10) and `pointerNote` (the
288
288
  known-unusable-op pointer sentence — the status/code are untouched, the
289
289
  note routes to the operation that works), plus the additive refusal /
290
- drift payloads (the `operations.recovery` `candidateIds` refusal, the #13
291
- shape-drift marker info). A non-pinned surface gets no `details` at all
290
+ drift payloads (the #13 shape-drift `candidateIds` refusal). A non-pinned surface gets no `details` at all
292
291
  (no speculation). On the MCP tools' served error payload, `details` is
293
292
  passed through verbatim (live-verified 2026-10-04: the W-D3 pointer note
294
293
  rides a real `time_entries.getPosted` vendor 500 on the wire; that 500 is gone since 0.32.0, the pointer-note mechanism is unchanged) and the
@@ -685,6 +684,22 @@ carry paging parameters.
685
684
  when the limit or the guard truncated the walk and more records exist past
686
685
  the stop point; `pageDetails.count` is never surfaced (it is the current
687
686
  page's record count — `<resource>.count` is the total).
687
+ - **`walk: { maxRows: N }` — an explicit export cap (issue #350).**
688
+ `N` is a positive integer and replaces the total `limit` cap, so
689
+ `{ walk: { maxRows: 10_000 } }` can return 10,000 rows. `limit` still
690
+ validates as 1..2500 (default 500) and only sizes pages in this mode:
691
+ `min(500, limit)`. `truncated` and `hasMore` are true when the final
692
+ page was sliced, a next cursor remains, or the 1000-page guard stopped
693
+ the walk, even below `maxRows`; an exhausted exact-cap result reports
694
+ false. No extra page is fetched to decide completeness. With no `walk`,
695
+ the ordinary cap and result shape remain unchanged (`truncated` is
696
+ omitted). `walk` and `latest` cannot be combined. Through `invoke`, the
697
+ unscoped-walk guard estimates the explicit `maxRows` cap independently
698
+ of `limit`; narrow the filter or pass `acknowledgeUnscoped: true` when
699
+ a large unscoped walk is intended. When names are enabled (per call or
700
+ by client labels), explicit walks apply the existing label budget per
701
+ fetched vendor page, matching the iterator; invoke does not label the
702
+ combined result again. Ordinary invoked queries keep their per-call budget.
688
703
  - **`latest: N` (issue #59) — the N highest-`id` rows without caller paging.**
689
704
  The vendor has NO sort parameter (the vendored spec's `QueryModel` is
690
705
  exactly `maxRecords` / `includeFields` / `filter`), so `latest` is a
@@ -780,23 +795,28 @@ Vendor filter structure (swagger `Filter`, recursive):
780
795
  `project.<field>` is resolved before the query: the parent entity is read
781
796
  (`IncludeFields: ['id']`, at most 300 rows) with the relation's conditions,
782
797
  and the leaves become `<relation>ID in [ids]`. Supported by `queryBounded`
783
- (and the invoke `query` op), `count` and `operations.aggregate`; the raw
784
- `query()`/`queryAll()` walks do not rewrite.
798
+ (and the invoke `query` op), `count`, `operations.aggregate`, and the raw
799
+ `query()`/`queryAll()` walks.
785
800
  - Inside an `and` (and at the top level) all leaves of one relation share ONE
786
801
  parent read; inside an `or` each relation leaf is its own read. Direct
787
802
  conditions are untouched and may be mixed freely.
788
803
  - No parent match: an `and` matches nothing; an `or` drops that branch. When
789
804
  the whole filter matches nothing the child call is not issued: `queryBounded`
790
805
  returns `{ items: [], fetched: 0, hasMore: false, pagesWalked: 0,
791
- relationNote }`, `count` returns `0`, aggregate group/sum carries
806
+ relationNote }` (plus `truncated: false` for an explicit `walk`),
807
+ `count` returns `0`, aggregate group/sum carries
792
808
  `relationNote`.
793
809
  - More than 300 matching parents: typed `AutotaskConfigError`, child call not
794
810
  issued (a capped parent set would be vendor-ordered, so partial totals would
795
811
  be biased). Narrow the relation conditions (e.g. a ticket date or status).
796
812
  - Pre-request (zero wire): unknown relation, a parent field outside the
797
- parent's vendored field set, multi-hop (`ticket.company.x`) and `udf: true`
798
- relation leaves are refused. The invoke field-name check skips relation
813
+ parent's vendored field set and multi-hop (`ticket.company.x`) are
814
+ refused. The invoke field-name check skips relation
799
815
  leaves (they are checked against the parent instead).
816
+ - Any dotted `udf: true` field is refused before metadata, parent queries,
817
+ scope counts or child requests, including self prefixes and unknown
818
+ prefixes (issue #351). Use an undotted tenant UDF name with `udf: true`;
819
+ `fieldCheck: false` does not bypass this rule.
800
820
 
801
821
  ```ts
802
822
  // company 123's time in October, hours per ticket: 2 calls (tickets ids, then the walk)
@@ -829,11 +849,20 @@ Vendor filter structure (swagger `Filter`, recursive):
829
849
  the cap). A GET path exists (`{ via: 'get' }`) and the SDK measures the
830
850
  serialized GET URL — over 2048 it throws `QueryTooLongError` (never a
831
851
  silent degradation).
832
- - `includeFields` passes through vendor column selection; the SDK never
833
- invents column selection — one exception: when `includeFields` is set the
834
- SDK always appends `id` (the vendor omits `id` from a row unless it is
835
- named in the selection, and every caller needs the id to act on the row —
836
- issue #59).
852
+ - **Own-field aliases (issue #352):** a single self prefix — the resource
853
+ key, or a relation name targeting that resource — is stripped from
854
+ ordinary filter fields, `includeFields`, and aggregate `groupBy` / `sum`.
855
+ On tickets, `ticket.title` and `tickets.title` mean `title`; on time
856
+ entries, `time_entries.hoursWorked` means `hoursWorked`, while
857
+ `ticket.companyID` remains a parent relation filter. Aggregate date
858
+ suffixes are preserved (`time_entries.dateWorked@month` becomes
859
+ `dateWorked@month`), and result keys use the canonical unprefixed names.
860
+ Foreign and multi-hop names are never converted into own-field aliases.
861
+ Projection normalization applies on the first page and every cursor
862
+ continuation, without mutating the caller's input.
863
+ - `includeFields` selects vendor columns after own-field normalization; the
864
+ SDK always appends `id` when omitted (the vendor omits `id` unless it is
865
+ selected, and callers need it to act on the row — issue #59).
837
866
 
838
867
  ## 6. Mutation safety
839
868
 
@@ -962,7 +991,48 @@ candidates and the error code when a resource could not resolve (empty
962
991
  the primary text field, exact match only; with `resolution.scanOnMiss` the
963
992
  bounded client-scan re-check (and its truncation cap) applies per resource. Returns
964
993
  `{ identifier, results: [{ resource, value, resolutionCost, scanned,
965
- scanTruncated, candidates, error? }], matches }`.
994
+ scanTruncated, candidates, error? }], matches, matched }`.
995
+
996
+ `match` defaults to `'exact'`. With `match: 'auto'`, every requested
997
+ resource finishes its exact lookup before a contains search can start. Only
998
+ a clean miss everywhere permits the contains sweep; an exact hit,
999
+ ambiguity, truncated scan, unsupported resolver or lookup error suppresses
1000
+ it. Numeric ids and digit strings never fall back to contains. Auth,
1001
+ rate-limit, server, network, timeout and policy faults propagate.
1002
+
1003
+ The contains sweep defaults to the client’s `resolution.maxScanRecords`,
1004
+ capped at 500. Optional `limit` must be an integer 1..500 and can only
1005
+ narrow that configured cap; invalid values refuse before lookup wire.
1006
+ Exact lookup sizes are unchanged. A singleton below the cap resolves; multiple matches
1007
+ report candidate ids, and a cap-full result reports `scanTruncated: true`
1008
+ and `RESOLUTION_TRUNCATED`, even if the cap is one. `matched` reports
1009
+ `'exact'` or `'contains'`; only the latter includes the full sweep in
1010
+ `contains`, including an empty sweep.
1011
+
1012
+ **`resolveStaff(client, identifier, opts?)`** looks up Autotask resources
1013
+ by exact email, userName or name. Text matches require `isActive: true`
1014
+ and exclude API users by default, using tenant picklist labels on
1015
+ `licenseType` / `userType` rather than fixed numeric values. Numeric ids
1016
+ fetch directly and bypass active/API-user exclusions. `emailOnly: true`
1017
+ treats even digit strings as email; `includeApiUsers: true` includes API
1018
+ users in text matches. Options form a closed plain-object bag: flags must be
1019
+ booleans when present and limit must be an integer 1..10. Unknown keys, nulls
1020
+ and malformed options refuse with CONFIG_ERROR before metadata or lookup wire.
1021
+
1022
+ Returns `{ identifier, lookup: 'id' | 'text', matches: [{ record, apiUser?,
1023
+ licenseTypeLabel? }], matchedCount, truncated, metadataState }`. `limit`
1024
+ is 1..10 (default 10); the text lookup reads at most 50 records.
1025
+ `matchedCount` is after exclusions and before the candidate limit, and is
1026
+ a lower bound when truncated. A live cursor or a candidate-limit cut sets
1027
+ `truncated: true`; a truncated singleton never proves uniqueness. Text
1028
+ misses return an empty `matches`; a numeric miss propagates `NOT_FOUND`.
1029
+ `metadataState` is `ok` when API-user labels were found, `none` when labels
1030
+ exist without an API label, `absent` when no labels are exposed, or `error`
1031
+ when metadata could not be read. Lookup continues if metadata is
1032
+ unavailable, and API-user classification is omitted when unproven. Text
1033
+ records honor the existing client `labels` setting through the shared row-label
1034
+ pass; optional `names` reports incomplete label coverage. Numeric gets keep
1035
+ their ordinary record shape.
966
1036
 
967
1037
  ### 7.3 `invoke(client, operation, args?, opts?)` — the write governor
968
1038
 
@@ -989,7 +1059,8 @@ the mode pinned (§7.4). The governor rules (pinned):
989
1059
  `queryBounded`** (server-side pagination): ONE invoke call returns a
990
1060
  bounded, complete-enough result — the vendor cursor walk happens inside
991
1061
  the SDK; the caller passes `limit` as the TOTAL cap (default 500, max
992
- 2500) and never owns a stream. `queryAll` keeps its raw behavior (the
1062
+ 2500), or an independent `walk.maxRows` export cap (section 4), and never
1063
+ owns a stream. `queryAll` keeps its raw behavior (the
993
1064
  unbounded walk; the catalog text steers the caller away for normal work);
994
1065
  - a query's `includeFields` NAMES and its `filter` condition FIELD NAMES are
995
1066
  validated **pre-request** (issues #119b + #125): the authority is the
@@ -1059,118 +1130,45 @@ be cross-mode-refused.
1059
1130
  Pinned by the full-surface suite (`test/operations/invoke/modes.test.ts`:
1060
1131
  all 1281 ops × 3 modes, zero wire).
1061
1132
 
1062
- **Harness approval policy (recommended).** Require approval on every tool
1063
- whose MCP annotation is `readOnlyHint !== true` — today exactly **267
1064
- tools**: the 265 dedicated write/delete tools (209 `effect=write` + 56
1065
- `effect=destructive`, every one of them has a dedicated tool) +
1066
- `autotask_write` + `autotask_delete`. The remaining **635 tools** (the 622 dedicated read tools + the 10 core read tools + `autotask_read` +
1067
- `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
1068
- and are safe ungated: every op they can route is `effect=read` (zero
1069
- mutating requests — pinned by the test inventory, §6 of the split design).
1070
- Refined class for the highest-risk ops: the **57 tools with
1071
- `destructiveHint: true`** (56 dedicated delete tools + `autotask_delete`)
1072
- additionally require `confirm === <operation key>` even inside
1073
- `autotask_delete`, and the 3 approval-gated writes
1074
- (`configuration_items.moveConfigurationItem`, `contacts.moveToCompany`,
1075
- `resources.transferOwnership`) require the same confirm inside
1076
- `autotask_write`. Migration: `autotask_invoke` is REPLACED by
1077
- `autotask_read`/`autotask_write`/`autotask_delete` — update harness config
1078
- (approve the 267 above) and remove any `autotask_invoke` entry. The rule is
1079
- derivable from the registry `effect` column (and the mode tools' fixed
1080
- annotations) — it never needs a hand-maintained name list.
1081
-
1082
- ### 7.5 `recovery(client, input, opts?)` — the one-call recovery/cleanup op (issue #9)
1133
+ <!-- BEGIN GENERATED:approval-policy -->
1134
+ **Harness approval policy (recommended).** Require approval on every tool whose MCP annotation is `readOnlyHint !== true` — today exactly **246 tools**: the 244 dedicated write/delete tools (188 `effect=write` + 56 `effect=destructive`, every one of them has a dedicated tool) + `autotask_write` + `autotask_delete`. The remaining **599 tools** (the 586 dedicated read tools + the 10 core read tools + `autotask_read` + `autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true` and are safe ungated: every op they can route is `effect=read` (zero mutating requests). Refined class for the highest-risk ops: the **57 tools with `destructiveHint: true`** (56 dedicated delete tools + `autotask_delete`) additionally require `confirm === <operation key>` even inside `autotask_delete`. The approval-gated writes (`configuration_items.moveConfigurationItem`, `contacts.moveToCompany`) require the same confirm inside `autotask_write`. Migration: `autotask_invoke` is REPLACED by `autotask_read`/`autotask_write`/`autotask_delete` — update harness config and remove any `autotask_invoke` entry. The rule is derivable from the registry `effect` column and the mode tools’ fixed annotations; it never needs a hand-maintained name list.
1135
+ <!-- END GENERATED:approval-policy -->
1136
+
1137
+ ### 7.5 the `create` dry-run delete-boundary warning (issue #9)
1083
1138
 
1084
1139
  The delete-boundary reality (§11.1, §11.9): the vendored spec defines NO
1085
1140
  DELETE on many entity paths — most of them the campaign's own resources. A
1086
1141
  `create` that cannot be undone through the API leaves the created record as
1087
- permanent residue once its id is lost (P1). `recovery` is the typed one-call
1088
- path out — a cross-resource special op in the `operations` group (effect
1089
- `write`, the governor's dry-run-first applies; `mcpTool`
1090
- `autotask_recovery`, tier `extended`):
1091
-
1092
- ```ts
1093
- import { recovery } from 'node-autotask/operations';
1094
- // find the scratch record a write just stamped (ONE bounded query, zero mutating wire):
1095
- const r = await recovery(client, { resource: 'companies', marker: 'FULL100W-1791110339' });
1096
- // r.delete.available === false + r.cleanup.note — the spec says the vendor has no DELETE.
1097
- // where the vendor DOES allow it (delete available):
1098
- const dry = await recovery(client, { resource: 'configuration_item_dns_records', marker: 'm', field: 'data', parentId: 42, action: 'delete' });
1099
- const applied = await recovery(client, { resource: 'configuration_item_dns_records', marker: 'm', field: 'data', parentId: 42, action: 'delete' },
1100
- { dryRun: false, confirm: 'operations.recovery' });
1101
- ```
1142
+ permanent residue once its id is lost (P1). The one-call recovery op that
1143
+ turned the boundary into a typed, bounded path (the marker-filtered find +
1144
+ confirm-gated delete that shared the SAME generated boundary note) was
1145
+ deleted in #289 — no server consumer; the boundary data, the typed error
1146
+ codes and the dry-run warning remain (§11.9).
1102
1147
 
1103
- - **`find`** (the default — a read): ONE bounded marker-filtered query
1104
- (server-side pagination; default `limit` 25, max 100) returns the matched
1105
- records' ids (`matches: [{ id, value }]`) + the delete-boundary fact
1106
- (`delete: { available, reason, authority }`) + the UI-only `cleanup` note.
1107
- Zero mutating wire.
1108
- - **`delete`** (destructive, dry-run-first): the dry run performs the search
1109
- and reports the match set with **zero DELETE wire** (`simulated: true`);
1110
- the apply requires the exact confirm string (`confirm ===
1111
- 'operations.recovery'` — the governor forwards the caller's `confirm`
1112
- through the trailing bag) and issues one DELETE per safe match id + one
1113
- verification read per id (`deleted: [{ id, verified, code? }]`).
1114
- - **the marker field**: the `contains` filter runs on the name-like field
1115
- the write stamped — default the plan's `markerField` for the resource
1116
- (the spec-derived deterministic priority over the vendored model's field
1117
- set), pass `field` to override; REQUIRED when the resource has no
1118
- `markerField` (a `CONFIG_ERROR` names the candidates).
1119
- - **P1 discipline — never a silent failure mode**:
1120
- - a delete on a delete-less resource refuses **PRE-SEARCH** with the typed
1121
- `DELETE_BOUNDARY` (zero wire — the boundary is the local spec audit,
1122
- never a raw vendor 405);
1123
- - a match whose id is beyond `Number.MAX_SAFE_INTEGER` is never a silent
1124
- skip: `delete` refuses with the typed `MARKER_UNSAFE_ID` (zero DELETE
1125
- wire); `find` marks the match `id: null` + `unsafeId: true`;
1126
- - a cap-tripped search (more matches than `limit`) refuses with the typed
1127
- `RECOVERY_TRUNCATED` — never a silent truncation, never a silent empty;
1128
- - an unknown `resource` refuses with the typed `RESOURCE_UNKNOWN` (the
1129
- same code `operations.relationshipMap` uses: carries the resource count
1130
- + the `autotask_catalog` pointer).
1131
- - **the create dry run warns**: on a delete-less resource,
1148
+ - **The create dry run warns**: on a delete-less resource,
1132
1149
  `client.<resource>.create(data, { dryRun: true })` carries the
1133
1150
  report-only `delete-boundary` check (`ok: true` — `wouldApply` is
1134
1151
  preserved) + the boundary note in `warnings[]` (the SAME generated note
1135
- `recovery` serves — the describe and dry-run channels cannot drift).
1136
- Pinned by `test/operations/recovery.test.ts` (11 tests) + the full-surface
1137
- governor suite (`test/operations/invoke/modes.test.ts`).
1152
+ the describe channel carries — the describe and dry-run channels cannot
1153
+ drift). Pinned by `test/resources/mutation-safety.test.ts` + the
1154
+ full-surface governor suite (`test/operations/invoke/modes.test.ts`).
1138
1155
 
1139
- ### 7.6 `fetchMany(client, items, opts?)` / `fetchByIds(client, items, opts?)` — bulk fetch by ids
1156
+ ### 7.6 `fetchByIds(client, items, opts?)` — bulk fetch by ids
1140
1157
 
1141
- Two cross-resource bulk reads with per-item typed results — one entry per
1158
+ The cross-resource bulk read with per-item typed results — one entry per
1142
1159
  input occurrence: `{ resource, id, found, value, error?: { code, httpStatus?,
1143
1160
  message } }`; a NOT_FOUND or fault on one item never aborts the rest; the
1144
- result is `{ results, ok, failed }`. Both refuse pre-request (zero wire):
1161
+ result is `{ results, ok, failed }`. The op refuses pre-request (zero wire):
1145
1162
  non-numeric string ids (pointing at `operations.resolveAny` — canonical
1146
1163
  integer strings above 2^53 pass with their exact digits), parent-scoped
1147
1164
  children (their `get` needs `parentId`), unknown `fields` keys (the complete
1148
1165
  problem list in one message), unsafe number ids (the canonical-string
1149
1166
  pointer). The op never fabricates rows: values come only from a vendor 2xx
1150
1167
  (query row or GET record); a miss is a typed outcome, never a null-padded
1151
- row.
1152
-
1153
- - **`fetchMany`** — up to 20 items (mixed resources; the pinned
1154
- `FETCH_MANY_MAX_ITEMS`; over → `CONFIG_ERROR` pre-request, zero wire),
1155
- one sequential GET per item. Per-item `fields?: string[]` is a client-side
1156
- projection (the GET returns the full model; projection is post-fetch, zero
1157
- extra wire).
1158
- **Live field validation (opt-in `opts.liveFields`, issue #66)**: when
1159
- `true`, each item's `fields` validate against the tenant's **live** entity
1160
- field metadata (the existing cached metadata channel — with
1161
- `fieldCache` configured a warm cache costs zero wire; a cold run is one
1162
- `entityInformation/fields` GET per DISTINCT resource in the batch) instead
1163
- of the vendored-spec field set alone. A resource whose metadata is
1164
- unavailable (network fault, vendor 5xx) falls back to the vendored-spec
1165
- set for THAT resource only — unavailability never throws and never blocks
1166
- the batch. When available the live set is authoritative: a field it lacks
1167
- is refused even if the spec lists it, and a live field the spec lacks is
1168
- accepted — a tenant whose live TimeEntry entity exposes `postedDate` (the
1169
- live record carries it even when the vendored model omits it) may project
1170
- it once `liveFields: true`; the default spec-only path still refuses it.
1171
- The default (no `liveFields`) is today's vendored-spec-only validation,
1172
- bit-for-bit, zero metadata wire. Field discovery in both modes:
1173
- `operations.fieldCatalog`.
1168
+ row. The per-item result shapes and the pre-request validation taxonomy are
1169
+ reused from the deleted `fetchMany` op (issue #289 — the shared `FetchMany*`
1170
+ result types kept their names).
1171
+
1174
1172
  - **`fetchByIds`** — up to 500 items (mixed resources; the pinned
1175
1173
  `FETCH_BY_IDS_MAX_ITEMS`; `opts.maxItems` may lower the cap, never raise
1176
1174
  it; over → `CONFIG_ERROR` pre-request, zero wire). **Query-first**: per
@@ -1185,8 +1183,7 @@ row.
1185
1183
  limited to the plan-pinned blind surfaces). Wire cost: per resource
1186
1184
  `ceil(uniqueIds/250)` query calls + one `get` per query-missed id — a
1187
1185
  clean 500-id single-resource batch is **2 wire** (down from 500 sequential
1188
- GETs); the blind-surface worst case is 2 + up to 500 gets (fetchMany
1189
- parity, never worse). A chunk-level vendor fault faults every item of that
1186
+ GETs); the blind-surface worst case is 2 + up to 500 gets. A chunk-level vendor fault faults every item of that
1190
1187
  chunk (the chunk's `code`/`httpStatus`) and the remaining chunks still run
1191
1188
  (input order is preserved across chunks and groups; a duplicate id gets its
1192
1189
  own entry with the identical outcome over one wire fetch).
@@ -1282,10 +1279,12 @@ wire):
1282
1279
  vendor casing via `operations.fieldCatalog`). An empty array / absent = the
1283
1280
  unfiltered call (the vendor-accepted no-filter shape rides the wire as
1284
1281
  `filter: []`; that is also the blind-predicate's stamp case).
1285
- - **`groupBy` / `sum`** = exact vendor field names (validated against the
1282
+ - **`groupBy` / `sum`** = vendor field names, accepting the single own-field
1283
+ prefix aliases of section 5 (normalized before validation and folding;
1284
+ result keys are unprefixed). Names are validated against the
1286
1285
  resource's vendored field set pre-request — a hallucinated field is a shape
1287
1286
  error, not a silent fold-miss; the complete problem list in one typed error,
1288
- zero wire). Non-numeric/absent `sum` values contribute 0 (documented).
1287
+ zero wire. Non-numeric/absent `sum` values contribute 0 (documented).
1289
1288
  - **`top`** (default 25, clamped 1..100) keeps the top-N groups (descending
1290
1289
  count; the deterministic group-key order breaks ties). **`limit`** (default
1291
1290
  2500 = the house `BOUNDED_QUERY_MAX_LIMIT`, clamped down to it) is the
@@ -1411,14 +1410,17 @@ notes cannot be deleted, so they never use create-then-delete.
1411
1410
 
1412
1411
  For per-ticket status / assignee / company use `operations.fetchByIds` on the group ticket ids
1413
1412
  (with `names: true`: `statusLabel`, `assignedResourceIDName`, `companyIDName`).
1414
- - **`tz` on the `dateWorked` ops** (`postedWithin`, `dateWorkedWindow`, `customRange` and the relative
1415
- windows): the vendor stores `dateWorked` as a date-only value at 00:00 UTC, so the wire bounds are always whole UTC days.
1416
- For `postedWithin` / `dateWorkedWindow` / `customRange` the `from`/`to` days ARE the days, so `tz` is
1417
- validated but never shifts them. For the relative windows (`today`, `last7`, `lastFullMonth`, ...) `tz` picks which
1418
- calendar dates the window covers in that zone and the bounds are whole UTC days of those dates. (Before 0.24.0 a `tz`
1419
- behind UTC, e.g. `America/Los_Angeles`, selected the NEXT UTC day; live-verified 2026-10-23.)
1413
+ - **`dateWorked` windows are whole UTC days (issue #66; consolidated in #290)**: the vendor stores
1414
+ `dateWorked` as a date-only value at 00:00 UTC, so the wire bounds are always whole UTC days.
1415
+ `time_entries.dateWorkedWindow` takes `from`/`to` as `YYYY-MM-DD` and the days ARE the bounds — the
1416
+ `tz` option is gone with the consolidation (before it, `tz` was validated but never shifted these
1417
+ bounds), and the relative windows (`today`, `last7`, `lastFullMonth`, ...) are cut. (Before 0.24.0 a
1418
+ `tz` behind UTC, e.g. `America/Los_Angeles`, selected the NEXT UTC day; live-verified 2026-10-23.)
1419
+
1420
+ ### 7.12 `timeEntries.approve` — time-entry approval levels (2026-10-26; cut in #292)
1420
1421
 
1421
- ### 7.12 `timeEntries.approve` — time-entry approval levels (2026-10-26)
1422
+ **Cut in #292** (confirmed unused write op — the CHANGELOG names the raw-composition
1423
+ alternative). The vendor facts below stand for anyone composing it by hand:
1422
1424
 
1423
1425
  `timeEntries.approve(id, opts?)` records one approval level for a time entry.
1424
1426
  The vendor keeps approvals in a separate entity (`BillingItemApprovalLevels`)
@@ -1550,9 +1552,8 @@ over a window, by account vertical": the registry op `operations.revenueRollup`
1550
1552
  label }`, absent otherwise). A label shared by several live picklist ids
1551
1553
  selects every one of them.
1552
1554
  - **`groupBy`** (default `['vertical', 'contractType']`): `vertical`
1553
- (`companies.marketSegmentID`), `contractType`, `contractCategory`,
1554
- `company`, `month` (the invoice month), `billingItemKind`, and the join
1555
- dimensions (#235):
1555
+ (`companies.marketSegmentID`), `contractType`, `company`, `month` (the
1556
+ invoice month), and the join dimensions (#235):
1556
1557
  - `contract` — `{ id, name }` (`contractName` joins the contract fetch).
1557
1558
  - `lineOfBusiness` — per billing line (`organizationalLevelAssociationID`;
1558
1559
  no contract-pointer fallback): three batch `id in` fetches (associations,
@@ -1563,26 +1564,18 @@ over a window, by account vertical": the registry op `operations.revenueRollup`
1563
1564
  their amount (LOB figures are floors). A row's `contracts` is "contracts
1564
1565
  with at least one line in this LOB", so per-LOB counts can sum to more than
1565
1566
  `totals.contracts`.
1566
- - `resource` — labour lines only (`billingItemType` Labor / Labor
1567
- Adjustment, or the FK fallback): `timeEntryID` → time entry → resource,
1568
- `{ id: resourceID, name: 'First Last' }`. Non-labour lines, and labour
1569
- lines with no time entry (e.g. an adjustment), are `{ id: null, label: 'n/a' }`.
1570
- - `billingCode` — `billingCodeID` → billing code `{ id, name }`; a line
1571
- without one is `{ id: null, label: 'none' }`.
1572
1567
 
1573
1568
  Each row key is `{ id, label?, name?, division? }`: picklist text in
1574
- `label`; vendor entity text (untrusted — company, contract, LOB level2,
1575
- resource and billing-code names) in `name`, and the LOB division (level1
1576
- name, also untrusted) in `division`. Missing values get their own row
1577
- (`unassigned`, `uncategorised`, `no LOB assigned`, `n/a`, `none`); a value
1578
- the picklist does not serve stays id-only and is listed in
1579
- `coverage.blindSurfaces`. A join id the `id in` fetch does not return is
1580
- never guessed: the line goes to `coverage.excluded.unresolvedAssociations`
1581
- (association or its level1/level2), `unresolvedTimeEntries` (time entry or
1582
- its resource) or `unresolvedBillingCodes`, with its amount. These buckets
1583
- (and `coverage.lineOfBusiness`) appear only when their dimension is grouped;
1584
- the line projection grows by `organizationalLevelAssociationID`,
1585
- `timeEntryID` / the type fields and `billingCodeID` the same way.
1569
+ `label`; vendor entity text (untrusted — company, contract and LOB level2
1570
+ names) in `name`, and the LOB division (level1 name, also untrusted) in
1571
+ `division`. Missing values get their own row (`unassigned`,
1572
+ `no LOB assigned`); a value the picklist does not serve stays id-only and is
1573
+ listed in `coverage.blindSurfaces`. A join id the `id in` fetch does not
1574
+ return is never guessed: the line goes to
1575
+ `coverage.excluded.unresolvedAssociations` (association or its level1/level2)
1576
+ with its amount. The bucket (and `coverage.lineOfBusiness`) appears only
1577
+ when its dimension is grouped; the line projection grows by
1578
+ `organizationalLevelAssociationID` the same way.
1586
1579
  - **Rows** are sorted by revenue descending, then ids; `top` (default 100,
1587
1580
  max 100) keeps the head and folds the tail into `rowsTruncated`. `totals`
1588
1581
  always cover every row.
@@ -1709,19 +1702,19 @@ const lob = await revenueRollup(client, { tenantWide: true, dateFrom: '2025-07-0
1709
1702
  ## 8. Resource surface
1710
1703
 
1711
1704
  <!-- BEGIN GENERATED:api-per-entity -->
1712
- Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page guarded), `queryBounded` (server-side pagination — one call walks the cursor until `limit` TOTAL records, default 500 / max 2500, with a `hasMore` flag; the MCP `query` surface, routed by `invoke`), `queryAll` (collect → `T[]` — the unbounded raw walk), `count`, `get` (id → record; `item: null` → `NotFoundError`), `create` / `update` / `delete` (where the vendor defines them — mutations take a `MutationOpts` bag: `{ dryRun, lastModifiedDate?, maxRetries? }` (issue #105: `maxRetries` overrides the configured retry budget for every request the mutation issues; `0` = exactly one send per request)), plus the helper floor where the plan declares it: `resolve` (all 121), `search` (40 — bounded contains on the resource text field), `getContext` (5 workflow resources), `findBy<Field>` (3), `createIfNotExists` (14). Entity metadata on every resource: `getEntityInfo`, `getFieldInfo`, `getUdfInfo` (UDF-capable 16). Parent-scoped children take `(parentId, id)` on the CRUD methods and require `parentId` on `createIfNotExists` (the scoped create POSTs the scoped collection; the dedup query stays global); `query`/`count`/`resolve`/`search` stay global (narrow with a `parentField` filter). Scoped `update` PATCHes the collection (the vendor keys the record by the id in the body).
1705
+ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page guarded), `queryBounded` (server-side pagination — one call walks the cursor until the total cap: `limit` (default 500 / max 2500), or independent `walk.maxRows` for exports (pages stay `min(500, limit)`); `hasMore` reports incomplete walks, and explicit walks also carry `truncated`; the MCP `query` surface, routed by `invoke`), `queryAll` (collect → `T[]` — the unbounded raw walk), `count`, `get` (id → record; `item: null` → `NotFoundError`), `create` / `update` / `delete` (where the vendor defines them — mutations take a `MutationOpts` bag: `{ dryRun, lastModifiedDate?, maxRetries? }` (issue #105: `maxRetries` overrides the configured retry budget for every request the mutation issues; `0` = exactly one send per request)), plus the helper floor where the plan declares it: `resolve` (122 of 129 — 7 lack it), `search` (41 — bounded contains on the resource text field), `getContext` (5 workflow resources); the `findBy<Field>` methods (findBySerial, findByTicketNumber, findByEmail) are class methods, not plan-declared special ops. Entity metadata on every resource: `getEntityInfo`, `getFieldInfo`, `getUdfInfo` (UDF-capable 16). Parent-scoped children take `(parentId, id)` on the CRUD methods and require `parentId` on `createIfNotExists` (the scoped create POSTs the scoped collection; the dedup query stays global); `query`/`count`/`resolve`/`search` stay global (narrow with a `parentField` filter). Scoped `update` PATCHes the collection (the vendor keys the record by the id in the body).
1713
1706
 
1714
1707
  | resource key | apiEntity | kind | tier | parent | standard ops present | special ops | mcpTool |
1715
1708
  |--------------|-----------|------|------|--------|----------------------|-------------|---------|
1716
1709
  | `appointments` | Appointment | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_appointment_get |
1717
1710
  | `billing_codes` | BillingCode | entity | extended | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_billingCode_get |
1718
1711
  | `billing_item_approval_levels` | BillingItemApprovalLevel | entity | extended | no | query, count, get, create | — | autotask_billingItemApprovalLevel_get |
1719
- | `billing_items` | BillingItems | entity | extended | no | query, count, get, update, resolve | customRange, getEntityInfo, getFieldInfo, getUdfInfo, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, today, yesterday | autotask_billingItems_get |
1720
- | `change_request_links` | ChangeRequestLink | entity | extended | no | query, count, get, create, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_changeRequestLinks_get |
1712
+ | `billing_items` | BillingItems | entity | extended | no | query, count, get, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_billingItems_get |
1713
+ | `change_request_links` | ChangeRequestLink | entity | extended | no | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_changeRequestLinks_get |
1721
1714
  | `checklist_libraries` | ChecklistLibrary | entity | extended | no | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibrary_get |
1722
1715
  | `checklist_library_checklist_items` | ChecklistLibraryChecklistItem | entity | extended | yes (checklist_libraries) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibraryChecklistItem_get |
1723
1716
  | `classification_icons` | ClassificationIcon | entity | advanced | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_classificationIcon_get |
1724
- | `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, prepareCrmWrite, searchByDomain, searchByIdentity, unbilledWork, upsertByName | autotask_company_get |
1717
+ | `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, prepareCrmWrite, searchByDomain, searchByIdentity, unbilledWork | autotask_company_get |
1725
1718
  | `company_alerts` | CompanyAlert | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyAlert_get |
1726
1719
  | `company_locations` | CompanyLocation | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyLocation_get |
1727
1720
  | `company_notes` | CompanyNote | entity | extended | yes (companies) | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyNote_get |
@@ -1736,15 +1729,15 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1736
1729
  | `configuration_item_ssl_subject_alternative_names` | ConfigurationItemSslSubjectAlternativeName | entity | extended | yes (configuration_items) | query, count, get, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_configurationItemSslSubjectAlternativeName_get |
1737
1730
  | `configuration_item_types` | ConfigurationItemType | entity | extended | no | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_configurationItemTypes_get |
1738
1731
  | `configuration_item_webhooks` | ConfigurationItemWebhook | entity | extended | yes (configuration_items) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_configurationItemWebhook_get |
1739
- | `configuration_items` | ConfigurationItem | entity | core | no | query, count, get, create, update, resolve, search, getContext | createIfNotExists, findBySerial, getEntityInfo, getFieldInfo, getUdfInfo, moveConfigurationItem, profile | autotask_configurationItems_get |
1732
+ | `configuration_items` | ConfigurationItem | entity | core | no | query, count, get, create, update, resolve, search, getContext | findBySerial, getEntityInfo, getFieldInfo, getUdfInfo, moveConfigurationItem, profile | autotask_configurationItems_get |
1740
1733
  | `contact_group_contacts` | ContactGroupContacts | entity | extended | no | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contactGroupContacts_get |
1741
1734
  | `contact_groups` | ContactGroups | entity | extended | no | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contactGroups_get |
1742
1735
  | `contact_webhooks` | ContactWebhook | entity | extended | yes (contacts) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contactWebhook_get |
1743
- | `contacts` | Contact | entity | core | yes (companies) | query, count, get, create, update, delete, resolve, search, getContext | findByEmail, getEntityInfo, getFieldInfo, getUdfInfo, moveToCompany, upsertByEmail | autotask_contact_get |
1736
+ | `contacts` | Contact | entity | core | yes (companies) | query, count, get, create, update, delete, resolve, search, getContext | findByEmail, fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, moveToCompany | autotask_contact_get |
1744
1737
  | `contract_billing_rules` | ContractBillingRule | entity | extended | yes (contracts) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractBillingRule_get |
1745
1738
  | `contract_block_hour_factors` | ContractBlockHourFactor | entity | extended | yes (contracts) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractBlockHourFactor_get |
1746
1739
  | `contract_blocks` | ContractBlock | entity | extended | yes (contracts) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractBlock_get |
1747
- | `contract_charges` | ContractCharge | entity | extended | yes (contracts) | query, count, get, create, update, delete, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractCharge_get |
1740
+ | `contract_charges` | ContractCharge | entity | extended | yes (contracts) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractCharge_get |
1748
1741
  | `contract_exclusion_billing_codes` | ContractExclusionBillingCode | entity | extended | yes (contracts) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractExclusionBillingCode_get |
1749
1742
  | `contract_exclusion_roles` | ContractExclusionRoles | entity | extended | yes (contracts) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractExclusionRoles_get |
1750
1743
  | `contract_exclusion_set_excluded_roles` | ContractExclusionSetExcludedRoles | entity | extended | yes (contract_exclusion_sets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractExclusionSetExcludedRole_get |
@@ -1760,22 +1753,22 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1760
1753
  | `contract_service_bundle_units` | ContractServiceBundleUnit | entity | extended | yes (contracts) | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractServiceBundleUnit_get |
1761
1754
  | `contract_service_bundles` | ContractServiceBundle | entity | extended | yes (contracts) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractServiceBundle_get |
1762
1755
  | `contract_service_units` | ContractServiceUnit | entity | extended | yes (contracts) | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractServiceUnit_get |
1763
- | `contract_services` | ContractService | entity | extended | yes (contracts) | query, count, get, create, update, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractService_get |
1756
+ | `contract_services` | ContractService | entity | extended | yes (contracts) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractService_get |
1764
1757
  | `contract_ticket_purchases` | ContractTicketPurchases | entity | extended | yes (contracts) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_contractTicketPurchase_get |
1765
- | `contracts` | Contract | entity | extended | no | query, count, get, create, update, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo, lineOfBusiness, profitability | autotask_contract_get |
1758
+ | `contracts` | Contract | entity | extended | no | query, count, get, create, update, resolve, search | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, lineOfBusiness, profitability | autotask_contract_get |
1766
1759
  | `countries` | Country | entity | advanced | no | query, count, get, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_country_get |
1767
1760
  | `currencies` | Currency | entity | advanced | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_currency_get |
1768
1761
  | `departments` | Department | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_department_get |
1769
1762
  | `domain_registrars` | DomainRegistrar | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_DomainRegistrar_get |
1770
1763
  | `expense_item_attachments` | ExpenseItemAttachment | entity | extended | yes (expense_items) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_expenseItemAttachment_get |
1771
- | `expense_items` | ExpenseItem | entity | extended | yes (expense_reports) | query, count, get, create, update, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_expenseItem_get |
1764
+ | `expense_items` | ExpenseItem | entity | extended | yes (expense_reports) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_expenseItem_get |
1772
1765
  | `expense_reports` | ExpenseReport | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_expenseReport_get |
1773
- | `holiday_sets` | HolidaySet | entity | advanced | no | query, count, get, create, update, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_holidaySet_get |
1774
- | `holidays` | Holiday | entity | advanced | yes (holiday_sets) | query, count, get, create, update, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_holiday_get |
1766
+ | `holiday_sets` | HolidaySet | entity | advanced | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_holidaySet_get |
1767
+ | `holidays` | Holiday | entity | advanced | yes (holiday_sets) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_holiday_get |
1775
1768
  | `invoice_templates` | InvoiceTemplate | entity | advanced | no | query, count, get | getEntityInfo, getFieldInfo, getUdfInfo | autotask_invoiceTemplate_get |
1776
- | `invoices` | Invoice | entity | extended | no | query, count, get, update, resolve | customRange, getEntityInfo, getFieldInfo, getUdfInfo, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, markupHtml, markupXml, pdf, today, yesterday | autotask_invoice_get |
1769
+ | `invoices` | Invoice | entity | extended | no | query, count, get, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo, markupHtml, markupXml, pdf | autotask_invoice_get |
1777
1770
  | `notification_history` | NotificationHistory | entity | advanced | no | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_notificationHistory_get |
1778
- | `opportunities` | Opportunity | entity | extended | no | query, count, get, create, update, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunity_get |
1771
+ | `opportunities` | Opportunity | entity | extended | no | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunity_get |
1779
1772
  | `opportunity_attachments` | OpportunityAttachment | entity | extended | yes (opportunities) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunityAttachment_get |
1780
1773
  | `opportunity_categories` | OpportunityCategory | entity | extended | no | query, count, get, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunityCategories_get |
1781
1774
  | `organizational_level1s` | OrganizationalLevel1 | entity | advanced | no | query, count, get, create, update | getEntityInfo, getFieldInfo, getUdfInfo | autotask_organizationalLevel1_get |
@@ -1783,7 +1776,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1783
1776
  | `organizational_level_associations` | OrganizationalLevelAssociation | entity | advanced | no | query, count, get, create, update | getEntityInfo, getFieldInfo, getUdfInfo | autotask_organizationalLevelAssociation_get |
1784
1777
  | `product_vendors` | ProductVendors | entity | extended | yes (products) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_productVendor_get |
1785
1778
  | `products` | Product | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_product_get |
1786
- | `project_charges` | ProjectCharge | entity | extended | yes (projects) | query, count, get, create, update, delete, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_projectCharge_get |
1779
+ | `project_charges` | ProjectCharge | entity | extended | yes (projects) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_projectCharge_get |
1787
1780
  | `project_notes` | ProjectNote | entity | extended | yes (projects) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_projectNote_get |
1788
1781
  | `phases` | Phase | entity | extended | yes (projects) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_phase_get |
1789
1782
  | `tasks` | Task | entity | extended | yes (projects) | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_task_get |
@@ -1795,21 +1788,21 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1795
1788
  | `quote_items` | QuoteItem | entity | extended | yes (quotes) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_quoteItem_get |
1796
1789
  | `quote_locations` | QuoteLocation | entity | extended | yes (quotes) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_quoteLocation_get |
1797
1790
  | `quote_templates` | QuoteTemplate | entity | extended | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_quoteTemplate_get |
1798
- | `quotes` | Quote | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_quote_get |
1791
+ | `quotes` | Quote | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo, withItems | autotask_quote_get |
1799
1792
  | `resource_role_queues` | ResourceRoleQueue | entity | extended | yes (resources) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_resourceRoleQueue_get |
1800
1793
  | `resource_roles` | ResourceRole | entity | extended | yes (resources) | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_resourceRole_get |
1801
1794
  | `resource_skills` | ResourceSkill | entity | extended | yes (resources) | query, count, get, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_resourceSkill_get |
1802
1795
  | `resource_time_off_additional` | ResourceTimeOffAdditional | entity | extended | yes (resources) | update, resolve | getByResource, getEntityInfo, getFieldInfo, getUdfInfo | autotask_resourceTimeOffAdditional |
1803
1796
  | `resource_time_off_approvers` | ResourceTimeOffApprover | entity | extended | yes (resources) | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_resourceTimeOffApprover_get |
1804
1797
  | `resource_time_off_balances` | ResourceTimeOffBalance | entity | extended | yes (resources) | get, resolve | getByResource, getByYear, getEntityInfo, getFieldInfo, getUdfInfo | autotask_resourceTimeOffBalance_get |
1805
- | `resources` | Resource | entity | core | no | query, count, get, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo, transferOwnership, whoAmI | autotask_resource_get |
1798
+ | `resources` | Resource | entity | core | no | query, count, get, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo, whoAmI, workload | autotask_resource_get |
1806
1799
  | `roles` | Role | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_role_get |
1807
1800
  | `sales_orders` | SalesOrder | entity | extended | no | query, count, get, update | getEntityInfo, getFieldInfo, getUdfInfo | autotask_salesOrder_get |
1808
1801
  | `service_call_task_resources` | ServiceCallTaskResource | entity | extended | yes (service_call_tasks) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_serviceCallTaskResource_get |
1809
1802
  | `service_call_tasks` | ServiceCallTask | entity | extended | yes (service_calls) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_serviceCallTask_get |
1810
1803
  | `service_call_ticket_resources` | ServiceCallTicketResource | entity | extended | yes (service_call_tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_serviceCallTicketResource_get |
1811
1804
  | `service_call_tickets` | ServiceCallTicket | entity | extended | yes (service_calls) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_serviceCallTicket_get |
1812
- | `service_calls` | ServiceCall | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_serviceCall_get |
1805
+ | `service_calls` | ServiceCall | entity | extended | no | query, count, get, create, update, delete, resolve | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo | autotask_serviceCall_get |
1813
1806
  | `service_level_agreement_results` | ServiceLevelAgreementResults | entity | advanced | no | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_serviceLevelAgreementResults_get |
1814
1807
  | `services` | Service | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_service_get |
1815
1808
  | `skills` | Skill | entity | extended | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_skill_get |
@@ -1822,13 +1815,13 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1822
1815
  | `tags` | Tag | entity | advanced | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_tag_get |
1823
1816
  | `task_secondary_resources` | TaskSecondaryResource | entity | extended | yes (tasks) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_taskSecondaryResources_get |
1824
1817
  | `tax_regions` | TaxRegion | entity | advanced | no | query, count, get, create, update | getEntityInfo, getFieldInfo, getUdfInfo | autotask_taxRegion_get |
1825
- | `ticket_additional_configuration_items` | TicketAdditionalConfigurationItem | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketAdditionalConfigurationItem_get |
1826
- | `ticket_additional_contacts` | TicketAdditionalContact | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketAdditionalContact_get |
1818
+ | `ticket_additional_configuration_items` | TicketAdditionalConfigurationItem | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketAdditionalConfigurationItem_get |
1819
+ | `ticket_additional_contacts` | TicketAdditionalContact | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketAdditionalContact_get |
1827
1820
  | `ticket_attachments` | TicketAttachment | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketAttachment_get |
1828
1821
  | `ticket_categories` | TicketCategory | entity | extended | no | query, count, get, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketCategories_get |
1829
1822
  | `ticket_category_field_defaults` | TicketCategoryFieldDefault | entity | extended | yes (ticket_categories) | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketCategoryFieldDefaults_get |
1830
1823
  | `ticket_change_request_approvals` | TicketChangeRequestApproval | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketChangeRequestApprovals_get |
1831
- | `ticket_charges` | TicketCharge | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo, moveToTicket | autotask_ticketCharge_get |
1824
+ | `ticket_charges` | TicketCharge | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo, moveToTicket | autotask_ticketCharge_get |
1832
1825
  | `ticket_checklist_items` | TicketChecklistItem | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketChecklistItem_get |
1833
1826
  | `ticket_checklist_libraries` | TicketChecklistLibrary | entity | extended | yes (tickets) | create, resolve | attachToDocument, getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketChecklistLibrary |
1834
1827
  | `ticket_history` | TicketHistory | entity | extended | no | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketHistory_get |
@@ -1837,8 +1830,8 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1837
1830
  | `ticket_notes` | TicketNote | entity | extended | yes (tickets) | query, count, get, create, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketNote_get |
1838
1831
  | `ticket_secondary_resources` | TicketSecondaryResource | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketSecondaryResources_get |
1839
1832
  | `ticket_webhooks` | TicketWebhook | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketWebhook_get |
1840
- | `tickets` | Ticket | entity | core | no | query, count, get, create, update, resolve, search, getContext | changeStatus, findByTicketNumber, forResource, fullContext, getEntityInfo, getFieldInfo, getUdfInfo, lineOfBusiness, openWithNoteAndAssign, slaHealthCheck, statusSemantics, summary, timeline | autotask_ticket_get |
1841
- | `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | approve, createForResource, createIfNotExists, customRange, dateWorkedWindow, getEntityInfo, getFieldInfo, getPosted, getUdfInfo, getUnposted, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, moveToTicket, postedWithin, today, yesterday | autotask_timeEntry_get |
1833
+ | `tickets` | Ticket | entity | core | no | query, count, get, create, update, resolve, search, getContext | changeStatus, findByTicketNumber, forResource, fullContext, getEntityInfo, getFieldInfo, getUdfInfo, lineOfBusiness, slaHealthCheck, statusSemantics, summary, timeline | autotask_ticket_get |
1834
+ | `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | dateWorkedWindow, getEntityInfo, getFieldInfo, getUdfInfo, moveToTicket | autotask_timeEntry_get |
1842
1835
  | `time_entry_attachments` | TimeEntryAttachment | entity | extended | yes (time_entries) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_timeEntryAttachment_get |
1843
1836
  | `time_off_requests` | TimeOffRequest | entity | extended | no | query, count, get, resolve | approve, getEntityInfo, getFieldInfo, getUdfInfo, reject | autotask_timeOffRequest_get |
1844
1837
  | `inventory_products` | InventoryProduct | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo, stockedItemsAdd, stockedItemsRemove, stockedItemsTransfer | autotask_inventoryProduct_get |
@@ -1850,7 +1843,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
1850
1843
  | `threshold_information` | — | integration | core | no | get | — | autotask_threshold_information |
1851
1844
  | `zone_information` | — | integration | advanced | no | get | — | autotask_zone_information |
1852
1845
 
1853
- 128 resources (121 entity + 7 integration). Rows are generated from the registry — do not hand-type.
1846
+ 136 resources (129 entity + 7 integration). Rows are generated from the registry — do not hand-type.
1854
1847
  <!-- END GENERATED:api-per-entity -->
1855
1848
 
1856
1849
  **`companies.search` include_family (issue #59).** `companies.search` is
@@ -1914,15 +1907,14 @@ drift). Records exist only for plan rows that are implemented/tested; the
1914
1907
  plan (`capabilities.plan.json`) is the single source of truth and the
1915
1908
  registry is its compiled form.
1916
1909
 
1917
- **Consumer-defaulted arguments (issue #115).** A manifest CONSUMER may fill an
1918
- argument this registry marks required when the caller omits it — the manifest
1919
- describes the SDK operation, not the consumer's tool contract. The one
1920
- instance today is `contracts.profitability`: the SDK requires
1921
- `dateFrom`/`dateTo`, and iit-mcp-autotask defaults both to the last full
1922
- calendar month (UTC) when the caller omits them. The served
1910
+ **SDK-default window (issues #115/#291).** `contracts.profitability` takes
1911
+ `dateFrom`/`dateTo` as an optional PAIR: both omitted → the window defaults to
1912
+ the last full calendar month (UTC) and the result's `period` carries
1913
+ `defaulted: true` plus a note (month label + UTC basis); exactly one supplied
1914
+ → a typed `CONFIG_ERROR` before any wire; both supplied → the existing
1915
+ validation. The consumer no longer fills the window — the served
1923
1916
  `metadata.args`/`metadata.usage` and the record's
1924
- `inputSchema.opts.description` state the caveat; a consumer that trusts the
1925
- raw manifest should apply the same contract.
1917
+ `inputSchema.opts.description` state the SDK default.
1926
1918
 
1927
1919
  ### 9.1 Generated artifact #5: `src/relationships.ts` (the relationship map + zone facts)
1928
1920
 
@@ -1947,11 +1939,11 @@ cannot drift from the plan):
1947
1939
  describe channel cannot drift.
1948
1940
  - `DELETE_BOUNDARY_NOTES` (issue #9) — resource → the spec-derived
1949
1941
  delete-boundary note (`plan.deleteBoundaries`): the create dry run's
1950
- `delete-boundary` warning and `operations.recovery` read the SAME map (the
1951
- describe and runtime channels cannot drift).
1952
- - `MARKER_FIELDS` (issue #9) — resource → the spec-derived name-like field
1953
- the recovery marker query filters on (the plan's `markerField`; absent when
1954
- the model has none).
1942
+ `delete-boundary` warning reads the map (the op that shared it,
1943
+ `operations.recovery`, was deleted in #289 — the dry-run and describe
1944
+ channels cannot drift).
1945
+ - `MARKER_FIELDS` (issue #9) — resource → the spec-derived name-like marker
1946
+ field (the plan's `markerField`; absent when the model has none).
1955
1947
  - `EMPTY_REASON_STAMPS` (issue #14) — resource → op → the typed empty-reason
1956
1948
  `{ surface, kind, note, authority, remedy }` (`plan.emptyReasons`, the
1957
1949
  ww29-sweep-pinned 184 rows): the runtime lookup `base.ts` attaches to
@@ -1973,10 +1965,10 @@ The per-resource entry also carries `deleteBoundary`
1973
1965
 
1974
1966
  Consumers: the runtime sentinel wrap (T10), `operations.relationshipMap`
1975
1967
  (S12-B — the `autotask_relationship_map` tool serves `RELATIONSHIP_MAP`
1976
- zero-wire by construction), the create dry run (§7.5) and
1977
- `operations.recovery` (issue #9). Drift-guarded: `capabilities:check` fails
1978
- when the artifact bytes ≠ a fresh re-derivation of the plan
1979
- (`test/scripts/relationships-artifact.test.ts` pins it TDD-style).
1968
+ zero-wire by construction) and the create dry run (§7.5). Drift-guarded:
1969
+ `capabilities:check` fails when the artifact bytes ≠ a fresh re-derivation
1970
+ of the plan (`test/scripts/relationships-artifact.test.ts` pins it
1971
+ TDD-style).
1980
1972
  ## 10. Response redaction (secret and personal fields)
1981
1973
 
1982
1974
  The SDK boundary redacts response field values. A vendor 2xx JSON body is
@@ -2061,8 +2053,9 @@ resource paths, of which **40** expose a create op (the stampable set; the
2061
2053
  13-record live-verified set above stays a distinct channel, `proof:
2062
2054
  'live-verified'`). The new `plan.deleteBoundaries` block (spec-derived,
2063
2055
  `proof: 'spec-derived'`) stamps each affected create's descriptor
2064
- (`metadata.deleteBoundary`), the create dry run (§7.5) and the one-call
2065
- `recovery` op read the SAME generated note — see §11.9.
2056
+ (`metadata.deleteBoundary`) and the create dry run (§7.5) read the SAME
2057
+ generated note (the one-call recovery op that shared it was deleted in
2058
+ #289) — see §11.9.
2066
2059
 
2067
2060
  ### 11.2 Blind count/query surfaces (get is the authority)
2068
2061
 
@@ -2108,7 +2101,7 @@ resource paths, of which **40** expose a create op (the stampable set; the
2108
2101
  type itself is discoverable via `configuration_item_types.query` (the wall is the
2109
2102
  FK, not the type).
2110
2103
 
2111
- ### 11.4 `time_entries.createForResource` — zone facts
2104
+ ### 11.4 `time_entries.create` — zone facts
2112
2105
 
2113
2106
  - The vendor **double-validates roleID** (an active general allocation code AND
2114
2107
  a Role reference); in ww29 the two sets are disjoint, so the task/ticket
@@ -2241,13 +2234,12 @@ of the data).
2241
2234
  everything — the sentinel notes, the record stamps, the generated
2242
2235
  `src/relationships.ts` (§9.1) and this section.
2243
2236
  - **S12 additions (PLANNED — land with the A/B workstreams, not yet on
2244
- main)**: 9 new registry ops — `tickets.openWithNoteAndAssign` /
2237
+ main)**: new registry ops — `tickets.openWithNoteAndAssign` /
2245
2238
  `tickets.changeStatus` / `tickets.fullContext` · `contacts.upsertByEmail` ·
2246
2239
  `companies.upsertByName` / `companies.fullProfile` ·
2247
- `configuration_items.profile` · `operations.fetchMany` (batch read) /
2248
- `operations.relationshipMap` (serves the §9.1 artifact zero-wire). Each
2249
- gets a curation row in `MCP_TOOL_OVERRIDES.json` at the post-merge
2250
- coordinator step.
2240
+ `configuration_items.profile` · `operations.relationshipMap` (serves the
2241
+ §9.1 artifact zero-wire). Each gets a curation row in
2242
+ `MCP_TOOL_OVERRIDES.json` at the post-merge coordinator step.
2251
2243
 
2252
2244
  ### 11.8 Live-validated create examples + the `createIfNotExists` dedupFields shapes
2253
2245
 
@@ -2346,11 +2338,13 @@ treat `entry.evidence` with the same trust you already apply to
2346
2338
  `vendorEvidence` on this surface (plan-grade provenance, not model-facing
2347
2339
  prose). The old `string[]` shape is gone.
2348
2340
 
2349
- ### 11.9 Delete-boundary awareness + the one-call recovery (issue #9)
2341
+ ### 11.9 Delete-boundary awareness (issue #9)
2350
2342
 
2351
2343
  The §11.1 wall (7 live-verified 405 endpoints) is now a **spec-derived
2352
2344
  channel** that covers the whole surface, not just the 13 live-verified
2353
- records. The audit (`scripts/lib/delete-boundary.mjs` — the same shared lib
2345
+ records. The one-call recovery op that turned this boundary into a typed,
2346
+ bounded path was deleted in #289 (no server consumer) — the channel below
2347
+ is the plan data + the create dry run that remain. The audit (`scripts/lib/delete-boundary.mjs` — the same shared lib
2354
2348
  the S11-F stamp channel and the F2 manifest note use) re-derives the
2355
2349
  vendored spec's DELETE-absence set, and the plan carries it as a NEW top-level
2356
2350
  block (distinct from `plan.blindSurfaces`, which the 6-pin set-equality gate
@@ -2366,27 +2360,25 @@ keeps untouched):
2366
2360
  `autotask_describe` surfaces it before the write).
2367
2361
  - **generated runtime data** — `src/relationships.ts` emits
2368
2362
  `DELETE_BOUNDARY_NOTES` (resource → note) + `MARKER_FIELDS` (resource → the
2369
- spec-derived name-like field the recovery marker query filters on, 22 pins)
2370
- from the same plan (zero wire by construction).
2363
+ spec-derived name-like marker field, 22 pins) from the same plan (zero
2364
+ wire by construction).
2371
2365
  - **the create dry run** (`src/resources/base.ts`) — on a delete-less
2372
2366
  resource the envelope gains the report-only `delete-boundary` check
2373
2367
  (`ok: true`) + the boundary note in `warnings[]`. The check is
2374
2368
  report-only on purpose: the write itself is valid, so `wouldApply` is
2375
2369
  preserved (the design's `ok: false` example would have broken the pinned
2376
2370
  `companies`/`tickets` create-dry-run contract).
2377
- - **`operations.recovery`** (§7.5) — the one-call recovery/cleanup op that
2378
- turns the boundary from a wall into a typed, bounded path: find the record
2379
- a write just created (one bounded marker query), and where the vendor
2380
- allows it, remove it (dry-run-first, confirm-gated apply, per-id
2381
- verification). The three new typed errors (`DELETE_BOUNDARY`,
2371
+ - **typed errors** — the three issue-#9 refusal codes (`DELETE_BOUNDARY`,
2382
2372
  `MARKER_UNSAFE_ID`, `RECOVERY_TRUNCATED`) + the reused `RESOURCE_UNKNOWN`
2383
- make every refusal explicit (never a raw 405, never a silent skip, never a
2384
- silent empty).
2385
- - **the gate** (`scripts/check-capabilities.mjs`) — the new channel has its
2386
- own set-equality clause: the block must equal a fresh spec re-derivation
2373
+ stay in the write-outcome taxonomy (`src/errors/mutation.ts`) after #289
2374
+ deleted the op that threw them: never a raw 405, never a silent skip,
2375
+ never a silent empty.
2376
+ - **the gate** (`scripts/check-capabilities.mjs`) — the channel has its own
2377
+ set-equality clause: the block must equal a fresh spec re-derivation
2387
2378
  over the stampable set, and EXACTLY the pinned create records carry
2388
2379
  `metadata.deleteBoundary` (content match). Pinned by
2389
- `test/operations/recovery.test.ts` + the full-surface governor suite.
2380
+ `test/scripts/check-capabilities.test.ts` (the F5 clauses) + the
2381
+ full-surface governor suite.
2390
2382
 
2391
2383
  ### 11.10 Typed empty reasons — `emptyReason` (issue #14)
2392
2384
 
@@ -2467,14 +2459,17 @@ blind) return `{ count, emptyReason? }` on a 0 — check
2467
2459
  existing contract, now with the reason alongside).
2468
2460
 
2469
2461
 
2470
- ### 11.11 Zero-wire planning reads + the bounded reachability probe (issue #11)
2462
+ ### 11.11 The zero-wire blind-surface + reachability plan data (issue #11)
2471
2463
 
2472
- Two ops serve the campaign's own discovery data at runtime (ZERO vendor
2473
- calls — generated plan data, drift-guarded by `capabilities:check` clause
2474
- 14), so an agent can read "what is blind on this zone" without burning
2475
- budget or guessing:
2464
+ The campaign's own discovery data ships as plan data (ZERO vendor calls —
2465
+ `plan.reachability` is derived into `src/relationships.ts`, drift-guarded
2466
+ by `capabilities:check` clause 14), so an agent can read "what is blind on
2467
+ this zone" without burning budget or guessing. The two ops that served it
2468
+ at runtime (`operations.blindSurfaces`, `operations.probeReachability`)
2469
+ were deleted in #289 (no server consumer) — the data and its derivation
2470
+ remain:
2476
2471
 
2477
- - **`operations.blindSurfaces`** — the 124-surface inventory: per surface the
2472
+ - **`BLIND_SURFACE_INVENTORY`** — the 124-surface inventory: per surface the
2478
2473
  kind (`unowned-blind` / `unfiltered-blind` / `unknown` — the 46
2479
2474
  data-with-rows surfaces ride `unknown` by design: the sweep's limit-2 read
2480
2475
  proves data, not the absence of it), the recorded `unfilteredRows` (0-2
@@ -2485,14 +2480,13 @@ budget or guessing:
2485
2480
  note. Clause 14 pins the row set equal to the plan's query-able resources,
2486
2481
  the counts derived from the rows, and the row-level kind invariant (a
2487
2482
  blind-class row must have 0 recorded rows).
2488
- - **`operations.probeReachability`** — re-probes chosen surfaces LIVE
2489
- (effect=read: route it through `autotask_read`): `{ resources?, maxWireCalls?,
2490
- confirmProbe }` — `confirmProbe: true` is REQUIRED (the probe issues real
2491
- vendor reads), caps of 40 surfaces / 80 wire calls with OVER-CAP REFUSAL
2492
- (never truncation), per-surface failure isolation (a 500 on one surface —
2493
- e.g. the W1 `ticket_history` contract 500 on the unfiltered read — is logged
2494
- with its machine code and the sweep continues), `wireUsed` single-sourced, zero results honest (a 0
2495
- from a blind surface proves nothing — the inventory's kind says why).
2483
+ - **`REACHABILITY_PROBE_DEFAULTS`** — the derived probe defaults: the hard
2484
+ wire cap (80), the default cap (40), the limit bounds (1-2, default 2)
2485
+ and the default target set — the derived non-data-full rows (the 70
2486
+ surfaces a re-discovery sweep starts from: the 69 blind +
2487
+ `ticket_history`). Derived zero-wire; the gate re-derives the target set,
2488
+ so it cannot drift from the inventory. (A 0 from a blind surface proves
2489
+ nothing — the inventory's kind says why.)
2496
2490
 
2497
2491
  ### 11.12 Zone-fact create guidance (issue #12)
2498
2492
 
@@ -2639,7 +2633,7 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2639
2633
  key. Unlike `pointerNote`, caveats ride the describe channel only (they are
2640
2634
  not attached to error details).
2641
2635
 
2642
- ### 11.13 Posted state from billing items (W-D3), `getPosted` / `getUnposted` / `postedWithin`, and drifted-create marker recovery (issues #10/#13/#237)
2636
+ ### 11.13 Posted state from billing items (W-D3), `dateWorkedWindow`, and drifted-create marker recovery (issues #10/#13/#237)
2643
2637
 
2644
2638
  - **W-D3 (live, ww29, re-verified 2026-10-27)** — this zone's TimeEntry entity
2645
2639
  has **no posted-state field**: time-entry reads carry no `postedDate` /
@@ -2659,51 +2653,39 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2659
2653
  condition is ever sent. The join is one `BillingItems` query per 250 ids;
2660
2654
  the entry read is capped at 500, so a call costs at most 1 read + 2 joins
2661
2655
  and a window with more than 500 entries returns the first 500 with
2662
- `hasMore` / `truncated` true and a `nextCursor` to continue from (or use
2656
+ `truncated` true and a `nextCursor` to continue from (or use
2663
2657
  `countOnly` for the window's totals; see the paging bullet below). A 429 or vendor fault propagates as a typed
2664
2658
  error. The API user therefore needs BillingItems read permission; a permission fault propagates as a typed error.
2665
- - **`time_entries.getPosted(opts?)` / `getUnposted(opts?)`** — `opts` is
2666
- `{ limit?, ticketID?, dateFrom?, dateTo?, cursor?, countOnly?, maxVendorCalls? }`
2667
- (the paging options: see the paging bullet below; `dateFrom`/`dateTo`:
2668
- inclusive `dateWorked` days, `YYYY-MM-DD`, never tz-shifted). They return
2669
- `{ items, hasMore, window, nextCursor, unconfirmed, approvedCount, unapprovedCount }` (a breaking
2670
- change from the bare `TimeEntry[]`, 0.32.0): `getPosted` items are entries
2671
- a billing item references; `getUnposted` items are entries none references.
2672
- `approvedCount` / `unapprovedCount` are informational (items with / without
2673
- `billingApprovalDateTime`) and are **not** posted state. `window` echoes the
2674
- bounds (`null` for a bound not given); `hasMore` true means the result is
2675
- partial (entry read capped, or a join page truncated — unconfirmed entries
2676
- are withheld, never called unposted). `dateFrom`/`dateTo` are optional, but a call without dates reads the tenant's first 500 time entries (effectively the oldest): a near-empty, misleading slice with `hasMore` true — pass a window.
2677
- - **`time_entries.postedWithin(from, to, opts?)`** — an inclusive UTC
2678
- `dateWorked` window (`from`/`to` are `YYYY-MM-DD` date strings — the S1c
2679
- guard forbids Date positionals), one bounded read (`limit` 1-500, default
2680
- 500 — the hard cap, issue #128: a larger window needs `time_entries.query`
2681
- with the same `dateWorked` filter, its 2500-row walk cap) returning
2682
- `{ items, postedCount, unpostedCount, window, nextCursor, unconfirmed, truncated }`; the counts come
2683
- from the billing-item join (any billing item referencing the entry), `opts.posted` narrows client-side after the join
2684
- (true = posted only, false = unposted only, omitted = both); the counts still split every entry read (before the narrowing), so `items.length` can differ from their sum and `ticketID` /
2685
- `resourceID` narrow the read. Live evidence for the visibility class
2686
- (unfiltered query 0 rows, windowed read has data) from 2026-10-06 still
2687
- stands; the posted/unposted split from that period does not.
2688
- - **`truncated` + the `dateWorkedWindow` alias + `tz` (issue #66)** — the
2689
- result is now `{ items, postedCount, unpostedCount, window, truncated }`:
2690
- `truncated` is true when the bounded read came back with a live
2691
- `pageDetails.nextPageUrl` (more matching rows exist; `requestCount` is the
2692
- MaxRecords echo, not a total, so it cannot carry this signal) —
2693
- the posted/unposted counts then cover the returned rows ONLY (at most
2694
- `limit`); re-query with a wider window or a narrowing filter to reconcile
2695
- the totals. `time_entries.dateWorkedWindow(from, to, opts?)` is the
2696
- unambiguous-named alias: the same wire, the same result (incl. `truncated`),
2697
- its own op identity (plan row / MCP tool
2698
- `autotask_timeEntry_date_worked_window` / operation label) — the name
2699
- names the `dateWorked` field the window is on; `postedWithin` keeps its
2700
- name and default semantics. Both accept `opts.tz` (an IANA zone, validated)
2701
- but, since 0.24.0, it never shifts the bounds: `dateWorked` is a date-only
2702
- value at 00:00 UTC, so `from`/`to` are the days (see §7.11). The same `tz`
2703
- opt is on every date-window op — see §11.22.
2704
- - **Paging past 500 entries and whole-window totals (issue #242)** — all four
2705
- ops (`getPosted`, `getUnposted`, `postedWithin`, `dateWorkedWindow`) take
2706
- three more options:
2659
+ - **`time_entries.dateWorkedWindow(from, to, opts?)`** — the ONE posted-state read
2660
+ (issue #290; absorbs `getPosted` / `getUnposted` / `postedWithin` and the
2661
+ relative windows). `from`/`to` are required inclusive UTC `dateWorked` days
2662
+ (`YYYY-MM-DD` date strings — the guard forbids Date positionals and refuses
2663
+ impossible days), one bounded read (`limit` 1-500, default 500 — the hard cap,
2664
+ issue #128: a larger window needs `time_entries.query` with the same
2665
+ `dateWorked` filter and a suitable `limit` or explicit `walk.maxRows` cap,
2666
+ section 4) returning
2667
+ `{ items, postedCount, unpostedCount, window, nextCursor, unconfirmed, truncated }`;
2668
+ the counts come from the billing-item join (any billing item referencing the
2669
+ entry) and split every entry read (before the narrowing), so `items.length`
2670
+ can differ from their sum; `opts.state` (`'posted'` / `'unposted'`, omitted =
2671
+ both) narrows `items` client-side after the join, and `ticketID` / `resourceID`
2672
+ narrow the read. The no-window form (a call without dates reading the
2673
+ tenant's first 500 time entries, effectively the oldest) is gone: a read with
2674
+ no `dateWorked` filter goes through `time_entries.query`. Live evidence for
2675
+ the visibility class (unfiltered query 0 rows, windowed read has data) from
2676
+ 2026-10-06 still stands; the posted/unposted split from that period does not.
2677
+ - **`truncated` (issue #66, consolidated in #290)** — `truncated` is true when
2678
+ the bounded read came back with a live `pageDetails.nextPageUrl` (more
2679
+ matching rows exist; `requestCount` is the MaxRecords echo, not a total, so
2680
+ it cannot carry this signal) or a join page was truncated — the
2681
+ posted/unposted counts then cover the returned rows ONLY (at most `limit`);
2682
+ re-query with a wider window or a narrowing filter to reconcile the totals.
2683
+ The `tz` option is gone with the consolidation (`dateWorked` is a date-only
2684
+ value at 00:00 UTC — the `from`/`to` days ARE the days, never shifted; see
2685
+ §7.11), and the `postedWithin` / `dateWorkedWindow` alias pair is one op now
2686
+ (plan row / MCP tool `autotask_timeEntry_date_worked_window`).
2687
+ - **Paging past 500 entries and whole-window totals (issue #242)** —
2688
+ `dateWorkedWindow` takes three more options:
2707
2689
  - `cursor?: string` — every rows result now carries `nextCursor: string | null`
2708
2690
  (the vendor's next-page cursor for the entry read). Pass it back as
2709
2691
  `opts.cursor` with the **same** window and filters to read the next page;
@@ -2714,12 +2696,11 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2714
2696
  continuation (`/V1.0/TimeEntries/query/next`), or a cursor longer than
2715
2697
  2,048 characters, is refused (`RESPONSE_REJECTED`) before any request. Every rows result also carries
2716
2698
  `unconfirmed: number`: the entries on this page whose billing-item join
2717
- page was truncated. They are withheld from `items` (getPosted /
2718
- getUnposted) and from both counts (postedWithin / dateWorkedWindow, which
2719
- also withhold them from `items` when `posted` is set; with `posted`
2720
- omitted they stay in `items`, unclassified), so a cursor loop does not
2721
- see them as posted or unposted; `unconfirmed` is 0 normally, and when it is
2722
- above 0 `hasMore` / `truncated` is also true. Sum it across a loop to know
2699
+ page was truncated. They are withheld from a `state`-narrowed `items`
2700
+ (with `state` omitted they stay in `items`, unclassified) and from both
2701
+ counts, so a cursor loop does not see them as posted or unposted;
2702
+ `unconfirmed` is 0 normally, and when it is above 0 `truncated` is also
2703
+ true. Sum it across a loop to know
2723
2704
  how many entries went unclassified (or use `countOnly`, which refuses
2724
2705
  instead).
2725
2706
  - `countOnly?: true` — returns only the whole window's totals:
@@ -2735,7 +2716,7 @@ metadata is (the `autotask_describe` channel, `capabilities.json`,
2735
2716
  and dropped another); a total equal to either count is accepted, so a
2736
2717
  window that grows or shrinks by the time the walk ends still succeeds, and
2737
2718
  one that changes mid-walk can fail or pass depending on timing: retry.
2738
- `limit` and `posted` do not apply; `cursor` is rejected with `countOnly`
2719
+ `limit` and `state` do not apply; `cursor` is rejected with `countOnly`
2739
2720
  (CONFIG_ERROR).
2740
2721
  - `maxVendorCalls?: number` — the `countOnly` budget (integer 1-200,
2741
2722
  default 30). The estimate is `1 + max(1, ceil(n / 500)) + ceil(n / 250) + 1`
@@ -2821,7 +2802,7 @@ resources, `include: ['fields']`; the closed opts bag is documented in
2821
2802
  the registry record). One bounded entityInformation GET per resource ×
2822
2803
  kind; the wire cost `resources × include` is REFUSED pre-request above
2823
2804
  the pinned cap (`MAX_FIELD_CATALOG_WIRE = 60`) — refused, never
2824
- truncated (the `probeReachability` contract). Per-resource vendor
2805
+ truncated (the bounded sweep contract). Per-resource vendor
2825
2806
  failures are recorded on the row (`error: { code, message }`) and never
2826
2807
  abort the sweep. Capability is PLAN-DERIVED, never hardcoded: the
2827
2808
  `getFieldInfo` / `getUdfInfo` row sets and each resource's vendor path
@@ -3334,57 +3315,35 @@ call.
3334
3315
  Pins: `test/operations/expandResourceNames.test.ts` (the plan row's `tests[]`)
3335
3316
  and `test/resources/cache-write-invalidation.test.ts` (mock wire only).
3336
3317
 
3337
- ### 11.22 Timezone-anchored date windows for billing items and invoices (issue #66)
3338
-
3339
- The time-entry window helpers pinned their bounds to UTC (the API is UTC)
3340
- while the node computes them in the configured timezone, and billing items /
3341
- invoices had no window helpers at all. Issue #66 makes the zone EXPLICIT and
3342
- additive, and mirrors the family:
3343
-
3344
- - **Two new window families** — the full mirror of the time-entry helpers
3345
- (the same 12 ops each: `today`, `yesterday`, `last24h`, `last7`, `last14`,
3346
- `last30`, `last90`, `lastFullWeek`, `lastFullFortnight`, `lastFullMonth`,
3347
- `lastFullQuarter`, `customRange`):
3348
- - `billing_items.<kind>(opts?)` — ONE bounded server-filtered query on
3349
- the billing item's date field **`itemDate`**
3350
- (`POST /V1.0/BillingItems/query`) → `Promise<BillingItems[]>`;
3351
- - `invoices.<kind>(opts?)` — ONE bounded server-filtered query on the
3352
- invoice's date-time field **`invoiceDateTime`**
3353
- (`POST /V1.0/Invoices/query`) → `Promise<Invoice[]>` (the invoices
3354
- model has no plain `invoiceDate` — `fromDate`/`toDate` are range
3355
- fields, not the invoice date).
3356
- - **The zone contract (shared)** — `opts.tz` takes an explicit IANA zone
3357
- (e.g. `'Australia/Sydney'`); OMITTED `tz` = UTC, the current behaviour
3358
- bit-for-bit (the regression pin). All three families are DATE-ONLY
3359
- (`dateWorked`, `itemDate`, `invoiceDateTime` are stored at T00:00:00Z): the
3360
- zone picks which calendar DATES a relative window means (what today / last
3361
- full month is in that zone) and the wire bounds are whole UTC days of them
3362
- (second-precision UTC ISO); explicit `from`/`to` days are never shifted and
3363
- `tz` is only validated. `time_entries` has worked this way since 0.24.0;
3364
- `billing_items` and `invoices` since 0.32.0 (behaviour change: before, a zone
3365
- behind UTC, e.g. America/New_York, sent a zone-converted instant window that
3366
- dropped the first day and added an extra day; zones ahead of UTC returned the
3367
- right rows by accident). A zone-converted instant window picks the wrong day
3368
- behind UTC. DST is
3369
- handled by inverting local midnights to UTC instants (a local day that
3370
- gains or loses an hour still pins both boundaries to its TRUE midnights —
3371
- never clock-hour arithmetic); `last24h` is rolling (`now − 24h → now`)
3372
- and TZ-invariant by construction. A zone `Intl.DateTimeFormat` rejects is
3373
- refused pre-request as a typed `CONFIG_ERROR` (zero wire). There is no
3374
- config-level timezone in v1 (the zone is per-call; a future
3375
- `config.timeZone` default would be an additive follow-up).
3376
- - **The existing family gains the same opt** — the 12 `time_entries`
3377
- window ops (incl. `customRange`) plus `postedWithin` and its
3378
- `dateWorkedWindow` alias accept `opts.tz`
3379
- with the contract above (date-only for time entries), so the whole window family accepts
3380
- the same `tz` option (default UTC unchanged).
3381
- - **Shape and wire** — each op is exactly ONE bounded query (`MaxRecords`
3382
- 1-500, default 500 — the hard cap, issue #128: a call without `limit` takes
3383
- the first 500 rows in id order; a window with more than 500 matching rows
3384
- needs the bounded query op with the same date-window filter — its 2500-row
3385
- walk cap; the `itemDate` / `invoiceDateTime` `gte`/`lte` filter rides the
3386
- wire), and the result is the plain `items[]` array — no counts
3387
- (the time-entry family shape).
3318
+ ### 11.22 The cut date-window families — `dateWorkedWindow` and bounded queries (issue #66, consolidated in #290)
3319
+
3320
+ Issue #66 made the window zone EXPLICIT (a `tz` option, default UTC) and
3321
+ mirrored the time-entry window family onto `billing_items` (`itemDate`) and
3322
+ `invoices` (`invoiceDateTime`) — twelve relative ops each. The consolidation
3323
+ (issue #290) cut all of them: ONLY the `time_entries` family is consolidated
3324
+ into the single op `time_entries.dateWorkedWindow(from, to, opts?)` (§11.13)
3325
+ — an inclusive, UTC-anchored `dateWorked` window with the posted/unposted
3326
+ split. The `billing_items` and `invoices` date-window families are CUT with
3327
+ no replacement op: reading those resources by date window goes through their
3328
+ bounded `query` ops on `itemDate` / `invoiceDateTime` (bullet below). The
3329
+ `tz` option is gone (`dateWorked` is a date-only value at 00:00 UTC; the
3330
+ `from`/`to` days ARE the wire bounds, never shifted, and impossible days are
3331
+ refused pre-request).
3332
+
3333
+ - **Reading billing items / invoices by date window** — use the bounded `query`
3334
+ ops: `billing_items.query` with an `itemDate` `gte`/`lte` filter on whole UTC
3335
+ days (`POST /V1.0/BillingItems/query`) and `invoices.query` with
3336
+ `invoiceDateTime` (`POST /V1.0/Invoices/query` — the invoices model has no
3337
+ plain `invoiceDate`; `fromDate`/`toDate` are range fields, not the invoice
3338
+ date). Each call is ONE bounded query (`MaxRecords` 1-500, default 500 — the
3339
+ hard cap, issue #128), the plain `items[]` result, no counts.
3340
+ - **The zone contract (history)** — before #290 the relative windows
3341
+ (`today`, `lastFullMonth`, ...) computed the calendar dates in `opts.tz` and
3342
+ sent whole UTC days (DST handled by inverting local midnights to UTC instants;
3343
+ `last24h` was rolling, `now − 24h → now`, TZ-invariant by construction); a
3344
+ zone the vendor's `Intl.DateTimeFormat` rejects was refused pre-request as a
3345
+ typed `CONFIG_ERROR` (zero wire). No relative-window op remains, so the
3346
+ contract only matters for historical receipts.
3388
3347
 
3389
3348
  ### 11.23 Free-text caps (issue #61)
3390
3349
 
@@ -3600,8 +3559,8 @@ returns the contract's reconciled P&L over a date window:
3600
3559
  (ticket `createDate`) as UTC instants, and bucketing slices the ISO
3601
3560
  string — in a UTC+10/11 tenant a ticket created late local time buckets
3602
3561
  one UTC day (possibly one month) earlier than its local date. The
3603
- `time_entries.getPosted` / `getUnposted` ops are NEVER called (the
3604
- profitability fold already holds the billing items, so the join is
3562
+ posted-state ops are gone (cut in #290 — the profitability fold
3563
+ already holds the billing items, so the join is
3605
3564
  in-memory with no extra wire); posted/unposted derives from the
3606
3565
  `billingItems.timeEntryID` join (unposted = no billing item exists yet).
3607
3566
  The structural exclusions (`/Services/`, `/ExpenseReports/`,
@@ -3682,7 +3641,7 @@ returns the contract's reconciled P&L over a date window:
3682
3641
  SERVICE lines)` (labour and charges never mask a missed recurring
3683
3642
  invoice) · `unposted_time { entries, hours, hoursAbsent?, value, absent? }`
3684
3643
  (billable time with no billing item yet — derived from the billing join,
3685
- never getPosted/getUnposted; `hours` = Σ `max(0, hoursToBill)`, or `null`
3644
+ never a posted-state op (cut in #290); `hours` = Σ `max(0, hoursToBill)`, or `null`
3686
3645
  + `hoursAbsent: 'HOURS_TO_BILL_MISSING'` when any of the month's unposted
3687
3646
  entries carries no numeric `hoursToBill` — unknown, never a summed 0; a
3688
3647
  deliberate `hoursToBill: 0` stays 0; `value` = Σ `hoursToBill ×
@@ -3865,8 +3824,8 @@ returns the contract's reconciled P&L over a date window:
3865
3824
  ### 11.27 Label siblings on query, aggregate and fetch (opt-in, issue #287)
3866
3825
 
3867
3826
  Rows served by the invoke `<resource>.query` op (the bounded query), the group
3868
- keys of `operations.aggregate`, and the found records of `operations.fetchMany`
3869
- / `operations.fetchByIds` carry label siblings **when opted in** (issue #287:
3827
+ keys of `operations.aggregate`, and the found records of `operations.fetchByIds`
3828
+ carry label siblings **when opted in** (issue #287:
3870
3829
  OFF by default — the label pass costs a field-catalog read plus up to 20 cold
3871
3830
  name reads per call, so a plain read returns raw ids), through the same
3872
3831
  `resolveCompositeNames` path the full-context composites use:
@@ -3875,9 +3834,9 @@ name reads per call, so a plain read returns raw ids), through the same
3875
3834
  siblings on for every read surface of that client (the MCP server sets it,
3876
3835
  server-side).
3877
3836
  - **The per-call key:** `names: true` in the query bag / the aggregate opts /
3878
- the `fetchMany` / `fetchByIds` opts turns them on for that call; `names:
3879
- false` turns them off (even when the client has `labels: true`); ABSENT =
3880
- the client's option (the off default). A non-boolean `names` is refused
3837
+ the `fetchByIds` opts turns them on for that call; `names: false` turns them
3838
+ off (even when the client has `labels: true`); ABSENT = the client's option
3839
+ (the off default). A non-boolean `names` is refused
3881
3840
  pre-request.
3882
3841
 
3883
3842
  | field on the row | sibling | example |
@@ -3940,9 +3899,8 @@ name reads per call, so a plain read returns raw ids), through the same
3940
3899
  per-call cap of 20 uncached name reads was hit — names already read are
3941
3900
  cached, so repeating or narrowing the call labels more"`. Unresolvable
3942
3901
  picklists add a note clause and a `reasons` entry per field (the
3943
- field-catalog honesty note / the value-not-served note); on `fetchMany` /
3944
- `fetchByIds` the notes merge across resources and `reasons` keys the plain
3945
- field name. Absent `names` = every sibling was resolved.
3902
+ field-catalog honesty note / the value-not-served note); on `fetchByIds`
3903
+ the notes merge across resources and `reasons` keys the plain field name. Absent `names` = every sibling was resolved.
3946
3904
  - Response size (full model, wrapped names): a tickets row grows ~40% (live,
3947
3905
  100 rows, 31 siblings per row, some of them null: 341,178 B -> 477,325 B;
3948
3906
  more when every FK is populated), a time_entries row ~77% (worst case, every
@@ -4011,6 +3969,35 @@ truncates first, then wraps) — so the explicit `… [+N chars truncated]`
4011
3969
  marker survives INSIDE the wrapped value and the closing marker is never
4012
3970
  cut off.
4013
3971
 
3972
+ **`sanitiseResult(client, result, operation)`** exposes that field projection
3973
+ as a zero-wire operations helper. Pass the registry key of the read that
3974
+ produced the result, for example `tickets.query`; an unknown key is a typed
3975
+ `CONFIG_ERROR`. It walks plain objects and arrays, wrapping strings in the
3976
+ operation's `metadata.untrustedFields`, including strings in arrays under
3977
+ those fields. `userDefinedFields[].value` strings are fenced even when the
3978
+ operation has no field stamp. Deeply nested containers are capped at 64
3979
+ levels with `[DEPTH]`.
3980
+ Inputs are never mutated, wrapping is idempotent, and non-string values
3981
+ and `[REDACTED]` pass through. SDK read primitives still return raw values.
3982
+
3983
+ This helper supplies field/UDF fencing. The host retains text cleaning,
3984
+ free-text caps and trusted formatting exemptions; apply those policies in
3985
+ the host before projection where needed. The bounded-query `noteChars`
3986
+ option remains a separate SDK cap applied before wrapping.
3987
+
3988
+ For hosts that need to clean or cap text before wrapping, the
3989
+ `node-autotask/untrusted` subpath exports
3990
+ `deepMarkUntrusted(value, fields, projector?)` and the callback type
3991
+ `UntrustedStringProjector = (text: string, target: object, key: string | number) => string`.
3992
+ The optional callback runs only for marked string values, including UDF
3993
+ values and strings in named arrays. `target` is the copied output
3994
+ container; `key` is its property name or array index, assigned after the
3995
+ callback returns. A host can record provenance in a `WeakMap` keyed by
3996
+ `target` and return the raw text, then clean, cap and wrap those marked
3997
+ values later. Containers exposed to the callback remain copies even if
3998
+ the callback returns unchanged text. Unmarked strings and non-string
3999
+ values do not invoke it; omitting the callback keeps the default wrapping.
4000
+
4014
4001
  ## 13. Doctor preflight (issue #2)
4015
4002
 
4016
4003
  `runDoctor(client)` (section 1.10) runs the bounded, sequential preflight