iit-mcp-autotask 0.38.4 → 0.38.6

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 (43) hide show
  1. package/.env.example +1 -1
  2. package/README.md +19 -124
  3. package/dist/config/constants.d.ts +1 -6
  4. package/dist/config/constants.js +1 -16
  5. package/dist/config/credentials.d.ts +1 -1
  6. package/dist/config/credentials.js +2 -4
  7. package/dist/config/load.js +2 -7
  8. package/dist/config.d.ts +2 -2
  9. package/dist/config.js +2 -2
  10. package/dist/credential.d.ts +22 -7
  11. package/dist/credential.js +0 -10
  12. package/dist/main.js +5 -1
  13. package/dist/tools/catalog-data.d.ts +1 -1
  14. package/dist/tools/catalog-data.js +1 -1
  15. package/dist/tools/composites.js +3 -3
  16. package/dist/tools/context/client.d.ts +3 -0
  17. package/dist/tools/context/client.js +8 -1
  18. package/dist/tools/context.d.ts +2 -3
  19. package/dist/tools/context.js +1 -2
  20. package/dist/tools/dispatch.js +18 -15
  21. package/dist/tools/lists.js +2 -2
  22. package/dist/tools/output-file.d.ts +5 -8
  23. package/dist/tools/output-file.js +30 -79
  24. package/dist/tools/results/envelope.js +0 -2
  25. package/dist/tools/results/errors.d.ts +1 -25
  26. package/dist/tools/results/errors.js +4 -44
  27. package/dist/tools/scope-guard.d.ts +2 -1
  28. package/dist/tools/scope-guard.js +3 -1
  29. package/dist/tools/server-version.d.ts +2 -2
  30. package/dist/tools/server-version.js +12 -2
  31. package/dist/tools/server.d.ts +1 -1
  32. package/package.json +3 -3
  33. package/server.json +2 -2
  34. package/dist/mocks/fixture-ops.d.ts +0 -92
  35. package/dist/mocks/fixture-ops.js +0 -121
  36. package/dist/mocks/fixtures.d.ts +0 -100
  37. package/dist/mocks/fixtures.js +0 -94
  38. package/dist/mocks/harness.d.ts +0 -110
  39. package/dist/mocks/harness.js +0 -194
  40. package/dist/mocks/wire.d.ts +0 -74
  41. package/dist/mocks/wire.js +0 -183
  42. package/dist/tools/context/pool.d.ts +0 -5
  43. package/dist/tools/context/pool.js +0 -9
package/.env.example CHANGED
@@ -77,7 +77,7 @@ AUTOTASK_PROFILE=readonly
77
77
  # fixed offsets (+10:00, Etc/GMT-10) and names without a region (EST, Japan) are refused. An invalid name
78
78
  # fails boot. Time entries stay calendar days (YYYY-MM-DD), not UTC bounds.
79
79
  # AUTOTASK_USER_TIMEZONE=Australia/Sydney
80
- # Removed in 0.6.0 (boot fails if set): AUTOTASK_READ_ONLY, AUTOTASK_WRITE_POLICY, AUTOTASK_WRITE_ALLOW,
80
+ # Removed in 0.6.0 (ignored with a boot warning, not honoured): AUTOTASK_READ_ONLY, AUTOTASK_WRITE_POLICY, AUTOTASK_WRITE_ALLOW,
81
81
  # AUTOTASK_SECRET_READS. authenticate.* (a credential-minting read) is always refused.
82
82
 
83
83
  # --- Per-tenant read cache (in-process; never cross-tenant; never on disk) ---
package/README.md CHANGED
@@ -8,20 +8,11 @@ context into one call, a generic read layer for the long tail, one result envelo
8
8
  packaged playbooks, strong security boundaries, and predictable per-tenant in-process state (pooled
9
9
  client + SDK-owned TTL caches).
10
10
 
11
- > **Package status (2026-10-29):** the runtime dependency `node-autotask` is published to public npm
12
- > at `0.39.1`; the committed `pnpm-lock.yaml` pins that
13
- > release with its integrity hash, so installs resolve deterministically. The dependency is always npm
14
- > (node-autotask pinned exactly at `0.39.1`) — never a `file:`, `git:`, `link:` or workspace reference
15
- > to a local checkout.
16
-
17
- > **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
18
- > changed. See [Migrating from 0.5.x](#migrating-from-05x) and the [CHANGELOG](CHANGELOG.md).
19
-
20
11
  ## Tool surface
21
12
 
22
13
  The **core** toolset (10 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
23
14
  `finance` (or `all`, 17 tools); the operator profile adds two. The whole tool surface (19 tools) is
24
- listed below. Every other operation in the SDK's 1,281-operation registry stays
15
+ listed below. Every other operation in the SDK's operation registry stays
25
16
  reachable through the generic layer: `autotask_catalog` finds the key, `autotask_describe` serves
26
17
  its contract (slim by default: transport and retry detail dropped, long text cut, the first two
27
18
  examples; `full: true` serves the complete record, `summary: true` about 2 KB), `autotask_read` runs it.
@@ -147,8 +138,8 @@ fields on writes: [`docs/company-location-fields.md`](docs/company-location-fiel
147
138
  Every write and delete is refused on the read path (`autotask_read`) before any request
148
139
  (`POLICY_DENIED`, status `denied`), on every profile. `authenticate.*` returns a live vendor token
149
140
  (credential-minting) and is never served, on any profile, through any tool. The 0.5.x variables `AUTOTASK_READ_ONLY`, `AUTOTASK_WRITE_POLICY`,
150
- `AUTOTASK_WRITE_ALLOW` and `AUTOTASK_SECRET_READS` were removed: boot fails if one is set, naming
151
- its replacement.
141
+ `AUTOTASK_WRITE_ALLOW` and `AUTOTASK_SECRET_READS` were removed: they are ignored (the server logs a
142
+ warning naming any unrecognised `AUTOTASK_*` variable at boot), so the profile governs.
152
143
 
153
144
  ### Operator profile (writes)
154
145
 
@@ -451,55 +442,6 @@ or in `.mcp.json` / Claude Desktop config:
451
442
  **From a clone**: `pnpm i && pnpm build`, then point the client at
452
443
  `node /abs/path/iit-mcp-autotask/dist/main.js --transport stdio`.
453
444
 
454
- ## Migrating from 0.5.x
455
-
456
- No aliases: old names fail as unknown tools.
457
-
458
- | 0.5.x | 0.6.0 |
459
- |-------|-------|
460
- | `catalog`, `describe {operation}`, `read {operation,args}`, `fetch_many`, `ticket_full_context`, `company_full_profile` | unchanged (core) |
461
- | `read_batch {items}` / `relationship_map {resource_key}` | `read {items}` / `describe {resource}` |
462
- | `resource_who_am_i`, `version`, `threshold_information` | `tenant_status {parts}` (`identity` + `impersonationResourceId`, `version`, `thresholds`, `server`, `time`) |
463
- | `resolve_any` | `resolve {identifier, resources?}` |
464
- | `search_across_resources {query,resources,limit}` | `resolve {identifier, match: "contains", resources, limit}` |
465
- | `company_` / `configurationItems_` / `contact_` / `project_` / `ticket_resolve` | `resolve {identifier, resource}` |
466
- | `ticket_find_by_ticket_number`, `configurationItems_find_by_serial`, `contact_find_by_email` | `resolve {identifier, resource, by: "ticket_number" \| "serial" \| "email"}` |
467
- | `ticket_` / `timeEntry_` / `serviceCall_` / `contract_` / `timeOffRequest_query` | `query {resource, filter, ...}` |
468
- | `ticket_sla_health_check`, `company_unbilled_work`, `contract_profitability`, `aggregate` | `read {operation: "tickets.slaHealthCheck" \| "companies.unbilledWork" \| "contracts.profitability" \| "operations.aggregate", args}` + `autotask_guide` |
469
- | `invoice_get {id}` / `invoice_markup {id, format}` | `invoice_get {id, format}` (finance) |
470
- | `quote_get`, `contact_full_profile`, `contract_full_profile`, `project_get_context`, `resource_workload` | same names (toolsets) |
471
- | `configurationItem_profile`, `serviceCall_full_profile` | `configuration_item_profile`, `service_call_full_profile` |
472
- | `write`, `delete`, `timeEntry_create` | `write {operation, args, idempotency_key?}` then `apply {plan}` (operator profile) |
473
- | env `READ_ONLY` / `WRITE_POLICY` / `WRITE_ALLOW` / `SECRET_READS` | `AUTOTASK_PROFILE` + `AUTOTASK_WRITE` + `AUTOTASK_STATE_DIR` + `AUTOTASK_RUN_SCOPE`; boot fails if an old variable is set |
474
- | CLI default stdio, bind on all interfaces | http on `127.0.0.1` by default; local clients pass `--transport stdio` |
475
- | results: per-tool shapes, errors as a text JSON payload | one envelope; the old payload is the envelope `data` |
476
-
477
- **Upgrading a 0.5.x deployment, step by step:**
478
-
479
- 1. Remove `AUTOTASK_READ_ONLY`, `AUTOTASK_WRITE_POLICY`, `AUTOTASK_WRITE_ALLOW` and
480
- `AUTOTASK_SECRET_READS` from the environment: 0.6.0 refuses to boot while one is set, and names
481
- the replacement.
482
- 2. Local (stdio) clients: add `--transport stdio` (or `MCP_TRANSPORT=stdio`). Without it 0.6.0
483
- starts HTTP on `127.0.0.1:8080` and the client hangs on stdin.
484
- 3. HTTP off loopback: set `AUTOTASK_HTTP_ALLOWED_HOSTS` and `AUTOTASK_HTTP_AUTH` (`gateway` or
485
- `tokens`); a server-held credential on HTTP needs `tokens`.
486
- 4. Read-only use needs nothing else. Pick toolsets with `AUTOTASK_TOOLSETS` if the agent used the
487
- service, records or finance tools.
488
- 5. Writes (the operator profile): set `AUTOTASK_WRITE` (see [docs/write-gate.md](docs/write-gate.md)) and `AUTOTASK_DEDUP` (a time entry needs `startDateTime` for its duplicate
489
- check, Service tickets need `startDateTime` and `endDateTime`, and `billingCodeID` must be an active
490
- general allocation code), create the state dir's parent, and set
491
- `AUTOTASK_PROFILE=operator`, `AUTOTASK_WRITE`, `AUTOTASK_STATE_DIR` and a run scope
492
- (`AUTOTASK_RUN_SCOPE` on stdio and in gateway mode, shared by every principal; `runScope` per token in tokens mode). The first boot creates the state
493
- dir (0700), its key and its lock. There is nothing to migrate: 0.5.x kept no write state.
494
- 6. Update agent prompts and playbooks to the new tool names (table above) and to the envelope's
495
- `status`/`next`.
496
-
497
- **Rolling back to 0.5.x:** pin the package back to version 0.5.0, restore the 0.5.x variables you
498
- removed in step 1 (and drop the 0.6.0 ones: 0.5.x ignores them but nothing should depend on them),
499
- and remove `--transport stdio` only if your client never passed a transport (0.5.x defaulted to
500
- stdio). Keep the operator state dir: its `audit.jsonl` is the record of every write 0.6.0 made, and
501
- 0.5.x neither reads nor touches it. Plans and the ledger are not portable to 0.5.x, which has no
502
- preview/apply path; a plan previewed under 0.6.0 cannot be applied after a rollback.
503
445
 
504
446
  ## Endpoints
505
447
 
@@ -694,7 +636,7 @@ negative-cached or replayed.
694
636
 
695
637
  ## Per-tenant pool & cache
696
638
 
697
- The stateless default is amended by one **user-approved deviation** (SCOPING Stage-4, 2026-10-06;
639
+ The stateless default is amended by one **user-approved deviation** (user-approved 2026-10-06;
698
640
  security decision D-1 ratified): a bounded **in-process** client pool with a per-tenant TTL read
699
641
  cache, because a fresh client per request would silently disable the SDK's local vendor-budget
700
642
  guards (rate limiter, 429 cooldown) — a worse security posture, not a safer one.
@@ -775,39 +717,23 @@ guards (rate limiter, 429 cooldown) — a worse security posture, not a safer on
775
717
  scans the image. This repo ships no GitHub Actions workflow; quality gates and publishing are
776
718
  run manually by the maintainer.
777
719
 
778
- Full control set: [`docs/architecture.md`](docs/architecture.md) (sections 7.3, 8 and 10) and the
779
- independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/0.6.0.md).
720
+ Full control set: [`docs/architecture.md`](docs/architecture.md) (sections 7.3, 8 and 10).
780
721
 
781
722
  ## SDK dependency
782
723
 
783
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.39.1`** (an exact pin —
784
- no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
785
- `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
786
- `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
787
- depends on a cloned checkout — no `file:`, `git:`, `link:` or workspace references. Range
788
- operators are prohibited on this dependency: a fresh lockfile-less install (every `npx`/`npm`
789
- user — the published tarball ships no lockfile) must resolve to the release this surface was
790
- reviewed against, or the plan-hash boot check refuses to start.
791
-
792
- **Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
793
- public registry at `0.37.0` (published 2026-10-28 UTC); the
794
- exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
795
- tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
796
- freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
797
- published-package baseline, and the pre-publish checklist still starts with a registry re-check
798
- (`npm view node-autotask version`) — which now passes.
799
-
800
- **Plan-hash drift gate.** At boot, before the listener binds, the installed `node-autotask`'s
801
- capability plan hash must equal the pin this surface was built and reviewed against
802
- (`assertPlanHash`); a mismatch refuses the boot naming the drifted groups. The server only runs
803
- against the SDK build it was reviewed against — a plan-changing SDK update can reach a user only
804
- through a deliberately re-pinned server release, so drift is refused at boot, not discovered
805
- mid-run.
806
-
807
- **Upgrading node-autotask** is a deliberate server release (pin bump, plan-hash re-pin, mirrored-surface
808
- re-review, gates) — not an npm-range float. The exact steps live in the [per-release
809
- checklist](CONTRIBUTING.md#per-release-checklist-node-autotask-upgrade) in CONTRIBUTING.md, and `pnpm
810
- fresh-install-check` re-proves the fresh-install property before every publish.
724
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at an exact pin** (see `package.json`;
725
+ no range operator) - the only vendor dependency; the others are `@modelcontextprotocol/server`
726
+ and `zod`. It never depends on a checkout (no `file:`, `git:`, `link:` or workspace references).
727
+ The published tarball ships no lockfile, so a fresh `npx`/`npm` install must resolve to the release
728
+ this surface was reviewed against, or the plan-hash boot check refuses to start.
729
+
730
+ **Plan-hash drift gate.** At boot, before the listener binds, the installed SDK's capability plan
731
+ hash must equal the pin this surface was reviewed against (`assertPlanHash`); a mismatch refuses
732
+ the boot naming the drifted groups.
733
+
734
+ **Upgrading node-autotask** is a deliberate server release (pin bump, plan-hash re-pin,
735
+ mirrored-surface re-review, gates), not a range float: see the
736
+ [per-release checklist](CONTRIBUTING.md#per-release-checklist-node-autotask-upgrade).
811
737
 
812
738
  ## Development
813
739
 
@@ -862,35 +788,4 @@ rate-limits per principal itself. Either way the vendor-side budget (10,000 req/
862
788
  per integration) is guarded by the SDK agent profile (8,000/hr local budget, 3 concurrent) and exposed
863
789
  live through `autotask_tenant_status`.
864
790
 
865
- ## Known SDK gaps
866
-
867
- None of the ten confirmed workflows is blocked. Confirmed absent from the SDK (recommend SDK
868
- issues — do not implement in this server):
869
-
870
- - **Knowledge base** — `KnowledgeBaseArticles` (43 paths) + `KnowledgeBaseCategories` (12): a real
871
- SDK gap vs the vendored spec.
872
- - **Warehouse inventory** — `InventoryItems` / `InventoryLocations` / `InventoryStockedItems{,Add,Remove,Transfer}` /
873
- `InventoryTransfers` / `InventoryItemSerialNumbers` (the SDK covers `InventoryProducts` only).
874
- - **Documents/articles ecosystem** — `Document*`/`Article*` family (the whole KB/document layer).
875
- - **Sales & pricing** — `SalesOrders`, `PriceList*`, `Taxes`/`TaxCategories`/`TaxRegions`,
876
- `Currencies`, `PaymentTerms`, `ShippingTypes`, standalone `ServiceBundles`.
877
- - **Attachment downloads** — metadata-only surfaces for company/project/task/resource/expense
878
- parents (the SDK covers `ticket_attachments` + `time_entry_attachments` downloads only).
879
-
880
- Not in the vendor REST spec at all (vendor surface gaps, not SDK gaps): reporting/BI, estimates,
881
- physical assets (Autotask models them as Configuration Items — covered).
882
-
883
- ## Known limitations & blockers
884
-
885
- | # | Item | Status |
886
- |---|------|--------|
887
- | B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,281-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
888
- | B2 | **Time-entry writes on ww29/australia-nz for API-only users**: vendor 500 (live-verified). Time-entry creation works where the API user is a real resource, through the operator profile (`log-time-entry`); on ww29 the write is classified `failed` (a 500 with a vendor `errors[]` body), never `unknown`. | zone limitation |
889
- | B3 | **`time_entries.getPosted`/`getUnposted`: the zone's TimeEntry entity has no `postedDate`**, so SDK 0.31 and earlier 500ed (and `postedWithin`/`dateWorkedWindow` silently counted every entry unposted). SDK 0.32.0 derives posted state from a `billing_items.timeEntryID` join and returns `{ items, hasMore, window, approvedCount, unapprovedCount }` (optional date-only `dateFrom`/`dateTo`; no dates reads only the first 500 entries). Needs BillingItems read permission (one extra request per call); `approvedCount`/`unapprovedCount` are not posted state. SDK 0.37.0 adds `cursor`/`nextCursor` (page past 500 entries), `unconfirmed` (entries whose posted state is unknown, kept out of both counts) and `countOnly: true` on the four posted-state ops. Reachable via the generic layer. | resolved in SDK 0.32.0 (not re-verified live) |
890
- | B4 | **CI create on tenants without products** (ww29 product wall: `products.count = 0`): `configuration_items.create` requires an existing `productID`. No dedicated CI-create in v1; the wall is documented on the generic side. | zone limitation |
891
- | B5 | **Blind count/query surfaces on ww29** (companies not owned by the caller; tickets): count/query return 0/empty even when data exists. Not fixable client-side; the surface carries the typed blind-surface sentinel — `get(id)`/full-profile are the authorities. | zone limitation |
892
- | B6 | **`node-autotask` npm publish**: the Phase-C install gate re-checks the registry; if still 404 the build pauses and escalates (SCOPING-resolved: the user publishes — no git/file dependency fallback). | open — user action |
893
-
894
- Zone-anchored curation note (recon F5): several description pins are facts observed on ww29
895
- (australia-nz) and are carried as zone-labelled pins, never silently deleted; on other tenants
896
- they may be zone/user-specific.
791
+ See [docs/architecture.md](docs/architecture.md) section 5 for intentional omissions and blocked capabilities.
@@ -1,4 +1,4 @@
1
- /** The closed value sets of the configuration: transports, profiles, toolsets, cache presets and the removed variables (issue #91). */
1
+ /** The closed value sets of the configuration: transports, profiles, toolsets, cache presets (issue #91). */
2
2
  /** `stdio` = local path (Claude Code / Claude Desktop / VS Code); `http` = remote (gateway). */
3
3
  export declare const TRANSPORTS: readonly ["stdio", "http"];
4
4
  export type Transport = (typeof TRANSPORTS)[number];
@@ -12,11 +12,6 @@ export type Profile = (typeof PROFILES)[number];
12
12
  /** The optional toolsets `AUTOTASK_TOOLSETS` selects; `core` is always registered. */
13
13
  export declare const TOOLSETS: readonly ["service", "records", "finance"];
14
14
  export type Toolset = (typeof TOOLSETS)[number];
15
- /**
16
- * The variables since removed, with the replacement each boot refusal names. Boot fails when
17
- * any is set: a deployment that still sets one expects a behaviour this build no longer has.
18
- */
19
- export declare const REMOVED_VARIABLES: Readonly<Record<string, string>>;
20
15
  /** The per-tenant read-cache preset every client this server builds gets. */
21
16
  export declare const CACHE_PRESETS: readonly ["on", "off"];
22
17
  export type CachePreset = (typeof CACHE_PRESETS)[number];
@@ -1,4 +1,4 @@
1
- /** The closed value sets of the configuration: transports, profiles, toolsets, cache presets and the removed variables (issue #91). */
1
+ /** The closed value sets of the configuration: transports, profiles, toolsets, cache presets (issue #91). */
2
2
  /** `stdio` = local path (Claude Code / Claude Desktop / VS Code); `http` = remote (gateway). */
3
3
  export const TRANSPORTS = ['stdio', 'http'];
4
4
  /**
@@ -9,21 +9,6 @@ export const TRANSPORTS = ['stdio', 'http'];
9
9
  export const PROFILES = ['readonly', 'operator'];
10
10
  /** The optional toolsets `AUTOTASK_TOOLSETS` selects; `core` is always registered. */
11
11
  export const TOOLSETS = ['service', 'records', 'finance'];
12
- /**
13
- * The variables since removed, with the replacement each boot refusal names. Boot fails when
14
- * any is set: a deployment that still sets one expects a behaviour this build no longer has.
15
- */
16
- export const REMOVED_VARIABLES = {
17
- AUTOTASK_READ_ONLY: 'AUTOTASK_PROFILE=readonly (the default) or AUTOTASK_PROFILE=operator',
18
- AUTOTASK_WRITE_POLICY: 'AUTOTASK_PROFILE=readonly (the default) or AUTOTASK_PROFILE=operator',
19
- AUTOTASK_WRITE_ALLOW: 'AUTOTASK_PROFILE=operator (writes are governed by the operator write path)',
20
- AUTOTASK_SECRET_READS: 'nothing: authenticate.* is always refused (credential-minting)',
21
- AUTOTASK_ACCESS_FILE: 'AUTOTASK_WRITE (see docs/write-gate.md)',
22
- AUTOTASK_ACCESS_KEY: 'AUTOTASK_WRITE (see docs/write-gate.md)',
23
- AUTOTASK_TOOL_EXCLUDE: 'AUTOTASK_TOOLSETS for reads, AUTOTASK_WRITE for writes',
24
- AUTOTASK_OPS_ALLOW: 'AUTOTASK_WRITE',
25
- AUTOTASK_POLICY_FILE: 'AUTOTASK_WRITE, AUTOTASK_DEDUP and AUTOTASK_WRITE_LIMITS (see docs/write-gate.md)',
26
- };
27
12
  /** The per-tenant read-cache preset every client this server builds gets. */
28
13
  export const CACHE_PRESETS = ['on', 'off'];
29
14
  /** The vendor's hard cap on requests per hour per integration (the local limiter must not exceed it). */
@@ -1,5 +1,5 @@
1
1
  import { type Config } from './schema.js';
2
- /** The recognised AUTOTASK_* names: the config schema's AUTOTASK_ keys + the removed 0.5.x set (derived, never hand-listed). */
2
+ /** The recognised AUTOTASK_* names: the config schema's AUTOTASK_ keys (derived, never hand-listed). */
3
3
  export declare function recognisedAutotaskVars(): string[];
4
4
  /** The set-but-unrecognised AUTOTASK_* env keys (stale 0.4.x-era vars the server never reads). */
5
5
  export declare function unrecognisedAutotaskVars(env: Record<string, string | undefined>): string[];
@@ -1,11 +1,9 @@
1
1
  /** Server credential intake and defaults: recognised variables, the credential load, the stdio defaults (issue #91). */
2
2
  import { loadAutotaskCredentialsFromEnv } from 'node-autotask';
3
- import { REMOVED_VARIABLES } from './constants.js';
4
3
  import { CONFIG_FIELDS } from './schema.js';
5
- /** The recognised AUTOTASK_* names: the config schema's AUTOTASK_ keys + the removed 0.5.x set (derived, never hand-listed). */
4
+ /** The recognised AUTOTASK_* names: the config schema's AUTOTASK_ keys (derived, never hand-listed). */
6
5
  export function recognisedAutotaskVars() {
7
- const schemaKeys = Object.keys(CONFIG_FIELDS.shape).filter((k) => k.startsWith('AUTOTASK_'));
8
- return [...new Set([...schemaKeys, ...Object.keys(REMOVED_VARIABLES)])].sort();
6
+ return Object.keys(CONFIG_FIELDS.shape).filter((k) => k.startsWith('AUTOTASK_')).sort();
9
7
  }
10
8
  /** The set-but-unrecognised AUTOTASK_* env keys (stale 0.4.x-era vars the server never reads). */
11
9
  export function unrecognisedAutotaskVars(env) {
@@ -1,6 +1,5 @@
1
1
  /** `loadConfig`: the environment in, a validated Config out (issue #91). */
2
2
  import { MALFORMED_CREDENTIAL_REFUSAL, detectCredentialShape, malformedCredentialBootMessage } from '../credential.js';
3
- import { REMOVED_VARIABLES } from './constants.js';
4
3
  import { CONFIG_FIELDS, schema } from './schema.js';
5
4
  import { unrecognisedAutotaskVars, loadServerCredentials } from './credentials.js';
6
5
  import { ConfigError } from './error.js';
@@ -15,9 +14,6 @@ import { ConfigError } from './error.js';
15
14
  * problem at once — a boot loop that surfaces one missing variable per restart wastes real minutes.
16
15
  */
17
16
  export function loadConfig(env = process.env) {
18
- const removed = Object.entries(REMOVED_VARIABLES)
19
- .filter(([name]) => env[name] !== undefined)
20
- .map(([name, use]) => ` ${name}: removed, use ${use}`);
21
17
  const parsed = schema.safeParse(env);
22
18
  // The pre-dispatch credential shape gate (issue #4a). Runs BEFORE the schema verdict is thrown,
23
19
  // so a malformed load is reported alongside (or instead of) the structural problems rather than
@@ -41,10 +37,9 @@ export function loadConfig(env = process.env) {
41
37
  // text served is this server's own, value-free: the loader's verdict and the load recipe.
42
38
  shapeIssues.push(` ${intake.sdkProblem.code}: ${MALFORMED_CREDENTIAL_REFUSAL}`);
43
39
  }
44
- if (parsed.success && shapeIssues.length === 0 && removed.length === 0)
40
+ if (parsed.success && shapeIssues.length === 0)
45
41
  return parsed.data;
46
42
  const problems = [
47
- ...removed,
48
43
  ...(parsed.success ? [] : parsed.error.issues.map((i) => ` ${i.path.join('.') || '(config)'}: ${i.message}`)),
49
44
  ];
50
45
  const all = [...problems, ...shapeIssues];
@@ -53,7 +48,7 @@ export function loadConfig(env = process.env) {
53
48
  const unrecognised = unrecognisedAutotaskVars(env);
54
49
  const section = [
55
50
  'AUTOTASK_* env vars:',
56
- ` recognised and set: ${Object.keys(CONFIG_FIELDS.shape).filter((k) => k.startsWith('AUTOTASK_') && env[k] !== undefined).sort().join(', ') || 'none'} — the vars this server actually reads (removed names above are not read)`,
51
+ ` recognised and set: ${Object.keys(CONFIG_FIELDS.shape).filter((k) => k.startsWith('AUTOTASK_') && env[k] !== undefined).sort().join(', ') || 'none'} — the vars this server actually reads`,
57
52
  ...(unrecognised.length > 0
58
53
  ? [
59
54
  ` unrecognised (ignored by this server): ${unrecognised.join(', ')}`,
package/dist/config.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * credential discovered by a model mid-conversation is a far worse failure than a container that
8
8
  * never came up.
9
9
  *
10
- * The credential model (SCOPING, approved): Autotask's three-header identity
10
+ * The credential model (docs/architecture.md 7.2, approved): Autotask's three-header identity
11
11
  * (`AUTOTASK_API_INTEGRATION_CODE` / `AUTOTASK_USERNAME` / `AUTOTASK_SECRET`) + the zone origin
12
12
  * (`AUTOTASK_BASE_URL`; the base path `/atservicesrest` is fixed, never configurable) + an
13
13
  * optional impersonation resource id. Under `stdio` the process environment IS the trust
@@ -15,7 +15,7 @@
15
15
  * without its own `X-Autotask-*` headers runs as this identity, and that identity is only ever
16
16
  * presented to the `AUTOTASK_BASE_URL` default origin (never to a caller-named one).
17
17
  */
18
- export { TRANSPORTS, PROFILES, TOOLSETS, REMOVED_VARIABLES, CACHE_PRESETS, VENDOR_HOURLY_CAP } from './config/constants.js';
18
+ export { TRANSPORTS, PROFILES, TOOLSETS, CACHE_PRESETS, VENDOR_HOURLY_CAP } from './config/constants.js';
19
19
  export type { Transport, Profile, Toolset, CachePreset } from './config/constants.js';
20
20
  export type { Config } from './config/schema.js';
21
21
  export { RUN_SCOPE, isLoopbackHost } from './config/parse.js';
package/dist/config.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * credential discovered by a model mid-conversation is a far worse failure than a container that
8
8
  * never came up.
9
9
  *
10
- * The credential model (SCOPING, approved): Autotask's three-header identity
10
+ * The credential model (docs/architecture.md 7.2, approved): Autotask's three-header identity
11
11
  * (`AUTOTASK_API_INTEGRATION_CODE` / `AUTOTASK_USERNAME` / `AUTOTASK_SECRET`) + the zone origin
12
12
  * (`AUTOTASK_BASE_URL`; the base path `/atservicesrest` is fixed, never configurable) + an
13
13
  * optional impersonation resource id. Under `stdio` the process environment IS the trust
@@ -15,7 +15,7 @@
15
15
  * without its own `X-Autotask-*` headers runs as this identity, and that identity is only ever
16
16
  * presented to the `AUTOTASK_BASE_URL` default origin (never to a caller-named one).
17
17
  */
18
- export { TRANSPORTS, PROFILES, TOOLSETS, REMOVED_VARIABLES, CACHE_PRESETS, VENDOR_HOURLY_CAP } from './config/constants.js';
18
+ export { TRANSPORTS, PROFILES, TOOLSETS, CACHE_PRESETS, VENDOR_HOURLY_CAP } from './config/constants.js';
19
19
  export { RUN_SCOPE, isLoopbackHost } from './config/parse.js';
20
20
  export { recognisedAutotaskVars, unrecognisedAutotaskVars, serverCredentialDefaults, hasServerCredentialDefaults } from './config/credentials.js';
21
21
  export { ConfigError } from './config/error.js';
@@ -1,3 +1,25 @@
1
+ /**
2
+ * credential.ts — the pre-dispatch shape gate for Autotask credential values, and the value-free
3
+ * description of a credential the operator-facing doctor reports.
4
+ *
5
+ * Why this exists (2026-10-07 incident, issues #4/#5/#6): a `.env` file may SINGLE-QUOTE a value on
6
+ * purpose (a secret containing `$` must be, so the shell does not expand it). The shell strips those
7
+ * quotes when the file is loaded with `set -a; . ./.env; set +a`; a quote-PRESERVING loader (most
8
+ * `dotenv` variants, a hand-rolled `split('=')`) hands the value over with its quotes intact. Such a
9
+ * value is printable ASCII, so it passes every transport check, reaches the vendor as
10
+ * `Secret: 'a$b'` and comes back as an opaque 401 that reads like a rotated credential — on an
11
+ * account where repeated auth failures risk a lock.
12
+ *
13
+ * This module closes that at the boundary, on BOTH intake paths, before any client is constructed.
14
+ * It never inspects, stores or reports a credential VALUE — only shape facts derived from it (a
15
+ * length, a two-character prefix, and which anomaly was seen), so every structure here is safe to
16
+ * log and to serialize.
17
+ *
18
+ * The anomaly vocabulary and the anomaly NAMES are this package's own (`detectCredentialShape`), so
19
+ * the envelopes and the doctor report stay stable across SDK versions; the SDK's own validator
20
+ * (`credentialShapeProblem` / `INVALID_CREDENTIAL_SHAPE`) is consumed at construction, where it is
21
+ * part of the SDK's contract with its callers.
22
+ */
1
23
  /**
2
24
  * The env-load pitfall every shape rejection points at, without claiming which loader was used.
3
25
  * Byte-for-byte the SDK's own pointer (`CREDENTIAL_SHAPE_POINTER` is the same sentence in the SDK's
@@ -53,12 +75,6 @@ export interface CredentialReport {
53
75
  export declare function reportCredential(env: Record<string, string | undefined>, name: string): CredentialReport;
54
76
  /** The one-line, value-free boot refusal message for a malformed credential variable. */
55
77
  export declare function malformedCredentialBootMessage(name: string, anomalies: readonly string[], length: number): string;
56
- /**
57
- * The SDK's own verdict on a value, when it has one (`credentialShapeProblem` from node-autotask
58
- * 0.8.0). Exposed so callers can check that this package's vocabulary and the SDK's agree — and so
59
- * the companion `INVALID_CREDENTIAL_SHAPE` error the SDK throws at construction can be surfaced as
60
- * this server's typed `CREDENTIAL_MALFORMED` refusal without re-implementing the predicate.
61
- */
62
78
  /**
63
79
  * The server's OWN refusal text for the SDK loader's `INVALID_CREDENTIAL_SHAPE` verdict (issue #4).
64
80
  * Built from the loader's machine code only — the SDK's message embeds a value summary
@@ -66,4 +82,3 @@ export declare function malformedCredentialBootMessage(name: string, anomalies:
66
82
  * loader's verdict, and the load recipe; no length, no prefix, no character of any value.
67
83
  */
68
84
  export declare const MALFORMED_CREDENTIAL_REFUSAL: string;
69
- export declare function sdkShapeProblem(value: string): string | undefined;
@@ -20,7 +20,6 @@
20
20
  * (`credentialShapeProblem` / `INVALID_CREDENTIAL_SHAPE`) is consumed at construction, where it is
21
21
  * part of the SDK's contract with its callers.
22
22
  */
23
- import { credentialShapeProblem } from 'node-autotask';
24
23
  /**
25
24
  * The env-load pitfall every shape rejection points at, without claiming which loader was used.
26
25
  * Byte-for-byte the SDK's own pointer (`CREDENTIAL_SHAPE_POINTER` is the same sentence in the SDK's
@@ -104,12 +103,6 @@ export function malformedCredentialBootMessage(name, anomalies, length) {
104
103
  return (`${name} holds a value whose shape cannot be right (length ${length}, ${anomalies.join(', ')}); ` +
105
104
  `this server refuses to boot rather than present a malformed credential to Autotask (issue #4). ${CREDENTIAL_LOAD_HINT}`);
106
105
  }
107
- /**
108
- * The SDK's own verdict on a value, when it has one (`credentialShapeProblem` from node-autotask
109
- * 0.8.0). Exposed so callers can check that this package's vocabulary and the SDK's agree — and so
110
- * the companion `INVALID_CREDENTIAL_SHAPE` error the SDK throws at construction can be surfaced as
111
- * this server's typed `CREDENTIAL_MALFORMED` refusal without re-implementing the predicate.
112
- */
113
106
  /**
114
107
  * The server's OWN refusal text for the SDK loader's `INVALID_CREDENTIAL_SHAPE` verdict (issue #4).
115
108
  * Built from the loader's machine code only — the SDK's message embeds a value summary
@@ -119,7 +112,4 @@ export function malformedCredentialBootMessage(name, anomalies, length) {
119
112
  export const MALFORMED_CREDENTIAL_REFUSAL = 'one or more credential values were refused by the SDK\'s canonical loader as a malformed shape; ' +
120
113
  'no request was sent. Fix the value in the environment this server reads and reload it. ' +
121
114
  CREDENTIAL_LOAD_HINT;
122
- export function sdkShapeProblem(value) {
123
- return credentialShapeProblem(value);
124
- }
125
115
  //# sourceMappingURL=credential.js.map
package/dist/main.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * only decision this file makes.
11
11
  */
12
12
  import { createMcpHandler } from '@modelcontextprotocol/server';
13
- import { ConfigError, loadConfig, serverCredentialDefaults } from './config.js';
13
+ import { ConfigError, loadConfig, serverCredentialDefaults, unrecognisedAutotaskVars } from './config.js';
14
14
  import { runMoves } from './moves.js';
15
15
  import { runDoctor } from './doctor.js';
16
16
  import { createLogger } from './logger.js';
@@ -166,6 +166,10 @@ function main() {
166
166
  // the logs must stay on stderr.
167
167
  const logSink = config.MCP_TRANSPORT === 'http' ? process.stdout : process.stderr;
168
168
  const log = createLogger(config.LOG_LEVEL, (line) => logSink.write(line + '\n'));
169
+ // Stale or mistyped AUTOTASK_* names are ignored, never a boot failure: say so once.
170
+ const unrecognised = unrecognisedAutotaskVars(env);
171
+ if (unrecognised.length > 0)
172
+ log.warn('ignoring unrecognised AUTOTASK_* variables (removed in an earlier version, or misspelled)', { variables: unrecognised });
169
173
  // Security V9 (FT-3): the lab-only allow-any opt-in is a security-posture warning for the
170
174
  // operator. It is NOT part of the operational NDJSON stream (under http that is stdout), so
171
175
  // it goes straight to stderr on every transport — the stream an operator/monitor reads for
@@ -96,7 +96,7 @@ export type AutotaskCacheOptions = {
96
96
  resourceNameCache?: false;
97
97
  };
98
98
  /**
99
- * The SDK cache options the preset selects (SCOPING Stage-4 deviation, user-approved):
99
+ * The SDK cache options the preset selects (approved cache deviation, docs/architecture.md 8.1):
100
100
  * `on` sends NO cache blocks — the SDK's default-on caches apply to every pooled tenant client
101
101
  * (read ~5 min + the plan-derived reference table, field ~5 min, resource-name ~1 h; in-process,
102
102
  * per-tenant, TTL-bounded, never cross-tenant, never on disk; the server holds no TTL value of
@@ -147,7 +147,7 @@ export function toolDescription(tool) {
147
147
  return text;
148
148
  }
149
149
  /**
150
- * The SDK cache options the preset selects (SCOPING Stage-4 deviation, user-approved):
150
+ * The SDK cache options the preset selects (approved cache deviation, docs/architecture.md 8.1):
151
151
  * `on` sends NO cache blocks — the SDK's default-on caches apply to every pooled tenant client
152
152
  * (read ~5 min + the plan-derived reference table, field ~5 min, resource-name ~1 h; in-process,
153
153
  * per-tenant, TTL-bounded, never cross-tenant, never on disk; the server holds no TTL value of
@@ -26,7 +26,7 @@ import { AutotaskConfigError, NotFoundError } from 'node-autotask';
26
26
  import { TICKET_FULL_CONTEXT_INPUT, COMPANY_FULL_PROFILE_INPUT, COMPANY_PROFILE_PART_LIMIT_DEFAULT, CONFIGURATION_ITEM_PROFILE_INPUT, PROJECT_GET_CONTEXT_INPUT, QUOTE_GET_INPUT, INVOICE_GET_INPUT, CONTACT_FULL_PROFILE_INPUT, SERVICE_CALL_FULL_PROFILE_INPUT, CONTRACT_FULL_PROFILE_INPUT, RESOURCE_WORKLOAD_INPUT, closedInput, } from './schemas.js';
27
27
  import { abortedAnswer } from './dispatch.js';
28
28
  import { crossResourceUntrusted, documentCore, markFilePayload, markResult, ok } from './results.js';
29
- import { resolveOutputTarget, writeOutputBytes, writeOutputText } from './output-file.js';
29
+ import { resolveOutputTarget, writeOutput } from './output-file.js';
30
30
  import { assertOperationPermitted } from '../policy.js';
31
31
  /** The cap on a fan-out `in` filter (the task-id join between two parts). */
32
32
  const IN_FILTER_CAP = 100;
@@ -265,7 +265,7 @@ export function buildInvoiceGet(ctx) {
265
265
  // that arrives pre-wrapped is a vendor forgery — the pinned SDK's read path returns raw).
266
266
  const doc = documentCore(result);
267
267
  if (target !== undefined) {
268
- const bytes = await writeOutputText(target, doc);
268
+ const bytes = await writeOutput(target, doc);
269
269
  return ok(`Invoice ${args.format} export written to the configured output directory (${bytes} bytes); the file holds the raw document, the result carries only the path and size.`, { format: args.format, result: { outputPath: target.path, bytes, encoding: 'utf-8' } });
270
270
  }
271
271
  const buf = Buffer.from(doc, 'utf8');
@@ -276,7 +276,7 @@ export function buildInvoiceGet(ctx) {
276
276
  // pdf: the SDK's file payload { encoding, byteLength, truncated, data? }.
277
277
  const payload = markResult(result, ctx.untrustedFields(op));
278
278
  if (target !== undefined && payload.truncated !== true && typeof payload.data === 'string') {
279
- const bytes = await writeOutputBytes(target, Buffer.from(payload.data, 'base64'));
279
+ const bytes = await writeOutput(target, Buffer.from(payload.data, 'base64'));
280
280
  return ok(`Invoice ${args.format} export written to the configured output directory (${bytes} bytes); the file holds the raw document, the result carries only the path and size.`, { format: args.format, result: { outputPath: target.path, bytes, encoding: 'binary' } });
281
281
  }
282
282
  markFilePayload(payload);
@@ -1,4 +1,5 @@
1
1
  import type { AutotaskConfig } from 'node-autotask';
2
+ import { type ClientPool } from 'node-autotask/pool';
2
3
  import type { Config } from '../../config.js';
3
4
  import type { Logger } from '../../logger.js';
4
5
  import type { ToolClient } from './types.js';
@@ -21,3 +22,5 @@ export declare function buildStdioClient(config: Config, log: Logger): {
21
22
  client: ToolClient;
22
23
  origin: string;
23
24
  };
25
+ /** The process-lifetime bounded per-tenant client pool of the http transport (security D-1, ratified). */
26
+ export declare function createTenantPool(log: Logger): ClientPool;
@@ -1,5 +1,6 @@
1
1
  /** SDK client construction: the base options, the placeholder client and the stdio client (issue #91). */
2
2
  import { AutotaskClient } from 'node-autotask';
3
+ import { createClientPool } from 'node-autotask/pool';
3
4
  import { serverCredentialDefaults } from '../../config.js';
4
5
  import { normalizeBaseUrl } from '../../base-hosts.js';
5
6
  import { cacheOptionsForPreset } from '../catalog-data.js';
@@ -34,7 +35,7 @@ export function buildSdkBaseOptions(config, log) {
34
35
  blockPrivateHosts: true,
35
36
  ...(defaultBaseUrl !== undefined ? { allowedOrigins: [defaultBaseUrl] } : {}),
36
37
  },
37
- // The SCOPING Stage-4 cache deviation (user-approved): `on` sends no cache blocks (the SDK
38
+ // The approved cache deviation, docs/architecture.md 8.1 (user-approved): `on` sends no cache blocks (the SDK
38
39
  // default-on caches) UNLESS the AUTOTASK_FIELD_CACHE_TTL_MS override is set — then the
39
40
  // scalar fieldCache block rides (the TTL pass-through); `off` opts every channel out
40
41
  // explicitly (every read re-dials the vendor) and is never touched by the override.
@@ -93,4 +94,10 @@ export function buildStdioClient(config, log) {
93
94
  });
94
95
  return { client, origin: normalized.origin };
95
96
  }
97
+ /** The process-lifetime bounded per-tenant client pool of the http transport (security D-1, ratified). */
98
+ export function createTenantPool(log) {
99
+ const pool = createClientPool({ maxSize: 128 });
100
+ log.info('tenant client pool ready', { maxSize: 128 });
101
+ return pool;
102
+ }
96
103
  //# sourceMappingURL=client.js.map
@@ -15,7 +15,7 @@
15
15
  * the pool + the credential-tuple config, so a vendor 401 deletes the entry (no negative
16
16
  * caching; security S1/C1.8, S3/C3.5).
17
17
  *
18
- * Client state (SCOPING Stage-4 deviation, user-approved): under `http` one BOUNDED in-process
18
+ * Client state (approved cache deviation, docs/architecture.md 8.1): under `http` one BOUNDED in-process
19
19
  * LRU pool (`node-autotask/pool`, default capacity 128) of tenant clients, keyed by the SDK's
20
20
  * SHA-256 of the NORMALISED credential tuple (user, integration code, secret, resolved base URL,
21
21
  * impersonation id) — raw credentials never appear in keys, logs or eviction messages. Each
@@ -33,6 +33,5 @@
33
33
  export type { ToolClient, HourlyBudget, QueryWalkParams, QueryWalkResult, ToolOps, ToolDeps, WriteDeps, ToolContext, ResolvedRequestScope, HttpScopeResolver } from './context/types.js';
34
34
  export { defaultToolOps } from './context/ops.js';
35
35
  export { deriveToolContext, releaseClientOnError } from './context/derive.js';
36
- export { buildSdkBaseOptions, buildStdioClient } from './context/client.js';
36
+ export { buildSdkBaseOptions, buildStdioClient, createTenantPool } from './context/client.js';
37
37
  export { createHttpScopeResolver } from './context/scope.js';
38
- export { createTenantPool } from './context/pool.js';
@@ -1,6 +1,5 @@
1
1
  export { defaultToolOps } from './context/ops.js';
2
2
  export { deriveToolContext, releaseClientOnError } from './context/derive.js';
3
- export { buildSdkBaseOptions, buildStdioClient } from './context/client.js';
3
+ export { buildSdkBaseOptions, buildStdioClient, createTenantPool } from './context/client.js';
4
4
  export { createHttpScopeResolver } from './context/scope.js';
5
- export { createTenantPool } from './context/pool.js';
6
5
  //# sourceMappingURL=context.js.map
@@ -260,24 +260,21 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
260
260
  // path and this bounded default is its contract; autotask_query refuses the missing filter because it is the guided surface.
261
261
  if (operation.endsWith('.query') && cleanArgs[0] === undefined)
262
262
  cleanArgs[0] = { limit: QUERY_LIMIT.parse(undefined) };
263
- const given = cleanArgs[0];
264
- // #173: a digit-string limit (a client that writes every value as text, e.g. Copilot Studio's
265
- // limit: "5") is that number; the SDK still range-checks it.
266
- if (given !== null && typeof given === 'object' && !Array.isArray(given) && typeof given.limit === 'string' && /^\d{1,9}$/.test(given.limit)) {
267
- cleanArgs[0] = { ...given, limit: Number(given.limit) };
263
+ // #173: a digit-string limit or latest (a client that writes every value as text, e.g. Copilot Studio's
264
+ // limit: "5") is that number; the SDK still range-checks it (it refuses a string `latest`, so the
265
+ // default limit and the scope guard below must see the number).
266
+ for (const key of ['limit', 'latest']) {
267
+ const given = cleanArgs[0];
268
+ const v = given?.[key];
269
+ if (given !== null && typeof given === 'object' && !Array.isArray(given) && typeof v === 'string' && /^\d{1,9}$/.test(v))
270
+ cleanArgs[0] = { ...given, [key]: Number(v) };
268
271
  }
269
- // The SDK validates `latest` with Number.isInteger too (a string is refused), so a digit-string
270
- // latest gets the same coercion (and the default limit and the scope guard below see the number).
271
- const given2 = cleanArgs[0];
272
- if (given2 !== null && typeof given2 === 'object' && !Array.isArray(given2) && typeof given2.latest === 'string' && /^\d{1,9}$/.test(given2.latest)) {
273
- cleanArgs[0] = { ...given2, latest: Number(given2.latest) };
274
- }
275
- const given3 = cleanArgs[0];
276
- if (given3 !== null && typeof given3 === 'object' && !Array.isArray(given3)) {
277
- assertFilterShape(given3.filter, operation);
272
+ const bagArg = cleanArgs[0];
273
+ if (bagArg !== null && typeof bagArg === 'object' && !Array.isArray(bagArg)) {
274
+ assertFilterShape(bagArg.filter, operation);
278
275
  // #234: ticket.<field> on tickets (task., project. likewise) is the own field
279
276
  const resource = operation.slice(0, operation.lastIndexOf('.'));
280
- const bag = { ...given3 };
277
+ const bag = { ...bagArg };
281
278
  if (bag.filter !== undefined)
282
279
  bag.filter = normalizeFilter(bag.filter, resource);
283
280
  if (Array.isArray(bag.includeFields))
@@ -319,6 +316,12 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
319
316
  if (verdict.aborted)
320
317
  return { aborted: true, result: undefined };
321
318
  guardNote = verdict.note;
319
+ // #245: a no-filter walk answers the first rows of the tenant table; say so, so the page is not read as the full answer
320
+ const f = sent.filter;
321
+ if ((f === undefined || (Array.isArray(f) && f.length === 0)) && sent.latest === undefined && typeof sent.limit === 'number') {
322
+ const noFilter = `no filter: the first ${sent.limit} rows in id order, not a scoped or complete answer.`;
323
+ guardNote = guardNote === undefined ? noFilter : `${guardNote} ${noFilter}`;
324
+ }
322
325
  }
323
326
  if (operation === 'contracts.profitability') {
324
327
  const rawBag = cleanArgs[1];