diff --git a/docs/API.md b/docs/API.md index a38da4b494..56a432dc14 100644 --- a/docs/API.md +++ b/docs/API.md @@ -968,18 +968,27 @@ delivery for all resources. - Endpoint fails closed if URL validation runtime is unavailable. ### Advanced Reporting (Pro) + +Report generation uses `settings:read`. For single-resource and fleet PDF +narration, an API token also needs `ai:execute` (or full access). Without it, +the report still succeeds with a deterministic summary and no AI provider call; +a single-resource PDF also explains the missing scope. Browser sessions and +server-run schedules keep their existing narration behaviour. AI budgets still +apply. GET and CSV never use AI narration. + - `GET /api/admin/reports/catalog` (admin, `settings:read`) - Returns the canonical reporting catalog for the settings surface, including locked-state teaser copy, enabled-surface guidance copy, performance-report options, canonical single-report filename subject, canonical fallback filename date style, and the nested VM inventory export definition. - Metadata route: readable without the `advanced_reporting` feature so locked admin surfaces can render the same reporting definition before upsell. - `POST /api/admin/reports/generate` (admin, `settings:read`) - Body fields: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional) - If `title` is omitted, the backend applies the canonical default title for that resource report. - - When Pulse Assistant is configured, the configured AI provider narrates a PDF's executive summary; each narration counts against the AI budget and appears in AI usage. If the call fails, times out or the budget is spent, the PDF falls back to the deterministic summary. CSV output is never narrated. The settings UI uses this method. + - When narration is permitted and Pulse Assistant is configured, the configured AI provider narrates a PDF's executive summary; each narration counts against the AI budget and appears in AI usage. If the call fails, times out or the budget is spent, the PDF falls back to the deterministic summary. CSV output is never narrated. The settings UI uses this method. - `GET /api/admin/reports/generate` (admin, `settings:read`) - Takes the same fields as query params and returns the same report, except that it never calls an AI provider: a PDF carries the deterministic summary and a note saying AI narration needs `POST`. Other methods return `405` with `Allow: GET, POST`. - `POST /api/admin/reports/generate-multi` (admin, `settings:read`) - Body fields: `resources` (1-50 entries of `{resourceType,resourceId}`), `format`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional) - If `title` is omitted, the backend applies the canonical default fleet report title. + - Fleet PDF narration follows the same `ai:execute` rule for API tokens; without it, the report uses the deterministic summary. CSV output is never narrated. - `GET /api/admin/reports/inventory/vms/export` (admin, `settings:read`) - Query params: `format` (`csv` only; optional and defaults to `csv`) - Exports the current fleet-wide VM inventory as spreadsheet-friendly CSV using the canonical runtime model. diff --git a/docs/MSP.md b/docs/MSP.md index 56e016f1f0..f7be97dcea 100644 --- a/docs/MSP.md +++ b/docs/MSP.md @@ -264,9 +264,15 @@ that client's resources: - **API**: `POST /api/admin/reports/generate` (single resource) and `POST /api/admin/reports/generate-multi` (up to 50 resources per report), returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID` - or an org-bound token. `GET /api/admin/reports/generate` with query - parameters still returns a single-resource report, but never adds the - Pulse Assistant narrative. + or an org-bound token. Report generation uses `settings:read`. For + single-resource and fleet PDF narration, an API token also needs + `ai:execute` (or full access). Without it, the report still succeeds with a + deterministic summary and no AI provider call; a single-resource PDF also + explains the missing scope. Browser sessions and server-run schedules keep + their existing narration behaviour. AI budgets still apply. GET and CSV + never use AI narration: `GET /api/admin/reports/generate` with query + parameters still returns a single-resource report without the Pulse + Assistant narrative. - **Schedules**: `GET`/`POST /api/admin/reports/schedules`, `PUT`/`DELETE /api/admin/reports/schedules/{id}`, and `POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 08f2275044..35c5383ec8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -10233,6 +10233,29 @@ be able to spend AI budget. The reporting catalog advertises the transport as it. `generate-multi` and the manual schedule run endpoint (`POST /api/admin/reports/schedules/{id}/run`) were already POST-only; due schedules run from the server's scheduler, not an HTTP request. +POST alone does not authorise narration by an API token. Both the +single-resource and fleet generation paths call `reportCallerMayUseAI` before +`resolveNarrator`: a token must carry `ai:execute` (including full-access +tokens), in addition to the route's `settings:read` generation scope. A +read-only token still receives the report, but with deterministic narration +and an existing-service-only findings lookup, never an AI service resolution +or provider call. The single-resource scope fallback uses +`scopeLimitedReportNarrator` and explains the missing scope; the fleet fallback +leaves `FleetNarrator` nil for the engine's heuristic summary. Browser sessions +and server-run schedules carry no API token and keep their existing narration +behaviour. An API-token-triggered schedule run shares the fleet narration gate; +its separate `settings:write` route scope and all schedule/budget rules remain +unchanged. GET stays deterministic even for tokens permitted to spend AI budget. +The canonical API/MSP references and their shipped mirrors distinguish read +permission, narration permission, and GET/CSV behaviour. `docsLinks.test.ts` +checks both method/scope branches and renders that guidance rather than +requiring the old whitespace-sensitive, unconditional POST branch. +`TestSingleReportAINarrationNeedsAIExecuteForTokens` and +`TestFleetReportAINarrationNeedsAIExecuteForTokens` exercise browser, read-only, +read-plus-monitoring, AI-execution, and full-access callers. +`TestSingleReportGETNeverUsesAIRegardlessOfTokenScopes` crosses those same +callers with GET and proves no narration/service resolution, the GET-specific +heuristic note, and preservation of existing findings. `TestContract_SingleReportAINarrationRequiresPOST` pins the rule on a real engine: GET, HEAD, PUT and DELETE make no narrator call and no AI service resolution, while POST makes one of each. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1232efe573..3e851f11a3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -6995,6 +6995,16 @@ for per-client single-resource reports and say a `GET` with query parameters still returns the report without the Pulse Assistant narrative, matching the server, which lets only `POST` narrate. Install, upgrade and provider bundle delivery are unchanged. +The same per-client report help distinguishes generation's `settings:read` +scope from narration's additional `ai:execute` permission for API tokens, for +single-resource and fleet PDFs alike. A token without spending authority still +gets a deterministic report, not a generation failure or an instruction to +broaden monitoring permissions. GET/CSV never narrate, while browser sessions, +server-run schedules and budgets keep their existing rules. Canonical and +shipped MSP copy must match; `docsLinks.test.ts` checks the rendered permission, +fallback and unchanged-surface guidance. The production Docs/router browser +fixture checks both API and MSP report sections on desktop and touch phone +without API requests, mutations or provider calls. ### Convergence log-reader compatibility (5 September 2026) diff --git a/frontend-modern/browser-tests/report-narration-scope-docs.cjs b/frontend-modern/browser-tests/report-narration-scope-docs.cjs new file mode 100644 index 0000000000..efcccfe3cf --- /dev/null +++ b/frontend-modern/browser-tests/report-narration-scope-docs.cjs @@ -0,0 +1,104 @@ +// Shipped report guidance under the production Docs page/router/renderer/CSS. +// This is help acceptance, not a provider call, report generation or deployment. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules', 'report-narration-scope-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-report-narration-scope'), + server: { host: '127.0.0.1', port: 5251, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + let browser; + const results = []; + try { + await server.listen(); + for (const [engine, width, dark] of [['chromium', 1280, false], ['webkit', 390, true]]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + const backendRequests = []; + const mutations = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('request', (request) => { + if (!['GET', 'HEAD'].includes(request.method())) mutations.push(request.method()); + }); + await page.route('**/api/**', async (route) => { + backendRequests.push({ method: route.request().method(), pathname: new URL(route.request().url()).pathname }); + await route.abort(); + }); + await page.goto('http://127.0.0.1:5251/browser-tests/docs-fragment-navigation.html', { + waitUntil: 'domcontentloaded', timeout: 60000, + }); + if (dark) await page.evaluate(() => document.documentElement.classList.add('dark')); + for (const [guide, heading] of [['API', 'advanced-reporting-pro'], ['MSP', 'per-client-reports']]) { + await page.evaluate((target) => { + history.pushState({}, '', target); + dispatchEvent(new PopStateEvent('popstate')); + }, `/docs/${guide}#${heading}`); + await page.waitForFunction((id) => { + const target = document.getElementById(id); + const rect = target?.getBoundingClientRect(); + return target && document.activeElement === target && rect.top >= 0 && rect.top < innerHeight; + }, heading); + const section = await page.evaluate((id) => { + const target = document.getElementById(id); + const paragraphs = []; + for (let node = target.nextElementSibling; node && !/^H[1-3]$/.test(node.tagName); node = node.nextElementSibling) { + paragraphs.push(node.textContent); + } + return paragraphs.join(' ').replace(/\s+/g, ' '); + }, heading); + for (const phrase of [ + 'Report generation uses settings:read.', + 'single-resource and fleet PDF narration, an API token also needs ai:execute (or full access).', + 'the report still succeeds with a deterministic summary and no AI provider call', + 'a single-resource PDF also explains the missing scope.', + 'Browser sessions and server-run schedules keep their existing narration behaviour.', + 'AI budgets still apply. GET and CSV never use AI narration', + ]) assert.ok(section.includes(phrase), `${guide}: ${phrase}`); + assert.ok(section.includes('POST /api/admin/reports/generate')); + assert.ok(section.includes('POST /api/admin/reports/generate-multi')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.waitForFunction(() => document.getAnimations().length === 0); + await page.screenshot({ path: path.join(artifacts, `${engine}-${guide}.png`) }); + results.push({ guide, engine, browserVersion: browser.version(), width, dark, + route: new URL(page.url()).pathname + new URL(page.url()).hash, + focusedVisibleHeading: true, narrationPermissionAndFallbackVisible: true, + unchangedSessionScheduleBudgetAndGETCSVGuidance: true, noHorizontalPageOverflow: true }); + } + assert.deepEqual(backendRequests, []); + assert.deepEqual(mutations, []); + assert.deepEqual(errors, []); + results.push({ engine, backendRequests, mutations, errors }); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + } + const documents = Object.fromEntries(['API', 'MSP'].map((guide) => [guide, + crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'public/docs', `${guide}.md`))).digest('hex')])); + const result = { result: 'passed', playwright: require('playwright/package.json').version, + documents, results, browserAndServerClosed: true, + scope: 'production documentation renderer and shipped assets; not report/provider/native/release acceptance' }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index a38da4b494..56a432dc14 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -968,18 +968,27 @@ delivery for all resources. - Endpoint fails closed if URL validation runtime is unavailable. ### Advanced Reporting (Pro) + +Report generation uses `settings:read`. For single-resource and fleet PDF +narration, an API token also needs `ai:execute` (or full access). Without it, +the report still succeeds with a deterministic summary and no AI provider call; +a single-resource PDF also explains the missing scope. Browser sessions and +server-run schedules keep their existing narration behaviour. AI budgets still +apply. GET and CSV never use AI narration. + - `GET /api/admin/reports/catalog` (admin, `settings:read`) - Returns the canonical reporting catalog for the settings surface, including locked-state teaser copy, enabled-surface guidance copy, performance-report options, canonical single-report filename subject, canonical fallback filename date style, and the nested VM inventory export definition. - Metadata route: readable without the `advanced_reporting` feature so locked admin surfaces can render the same reporting definition before upsell. - `POST /api/admin/reports/generate` (admin, `settings:read`) - Body fields: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional) - If `title` is omitted, the backend applies the canonical default title for that resource report. - - When Pulse Assistant is configured, the configured AI provider narrates a PDF's executive summary; each narration counts against the AI budget and appears in AI usage. If the call fails, times out or the budget is spent, the PDF falls back to the deterministic summary. CSV output is never narrated. The settings UI uses this method. + - When narration is permitted and Pulse Assistant is configured, the configured AI provider narrates a PDF's executive summary; each narration counts against the AI budget and appears in AI usage. If the call fails, times out or the budget is spent, the PDF falls back to the deterministic summary. CSV output is never narrated. The settings UI uses this method. - `GET /api/admin/reports/generate` (admin, `settings:read`) - Takes the same fields as query params and returns the same report, except that it never calls an AI provider: a PDF carries the deterministic summary and a note saying AI narration needs `POST`. Other methods return `405` with `Allow: GET, POST`. - `POST /api/admin/reports/generate-multi` (admin, `settings:read`) - Body fields: `resources` (1-50 entries of `{resourceType,resourceId}`), `format`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional) - If `title` is omitted, the backend applies the canonical default fleet report title. + - Fleet PDF narration follows the same `ai:execute` rule for API tokens; without it, the report uses the deterministic summary. CSV output is never narrated. - `GET /api/admin/reports/inventory/vms/export` (admin, `settings:read`) - Query params: `format` (`csv` only; optional and defaults to `csv`) - Exports the current fleet-wide VM inventory as spreadsheet-friendly CSV using the canonical runtime model. diff --git a/frontend-modern/public/docs/MSP.md b/frontend-modern/public/docs/MSP.md index 56e016f1f0..f7be97dcea 100644 --- a/frontend-modern/public/docs/MSP.md +++ b/frontend-modern/public/docs/MSP.md @@ -264,9 +264,15 @@ that client's resources: - **API**: `POST /api/admin/reports/generate` (single resource) and `POST /api/admin/reports/generate-multi` (up to 50 resources per report), returning PDF or CSV. In shared-process mode, scope with `X-Pulse-Org-ID` - or an org-bound token. `GET /api/admin/reports/generate` with query - parameters still returns a single-resource report, but never adds the - Pulse Assistant narrative. + or an org-bound token. Report generation uses `settings:read`. For + single-resource and fleet PDF narration, an API token also needs + `ai:execute` (or full access). Without it, the report still succeeds with a + deterministic summary and no AI provider call; a single-resource PDF also + explains the missing scope. Browser sessions and server-run schedules keep + their existing narration behaviour. AI budgets still apply. GET and CSV + never use AI narration: `GET /api/admin/reports/generate` with query + parameters still returns a single-resource report without the Pulse + Assistant narrative. - **Schedules**: `GET`/`POST /api/admin/reports/schedules`, `PUT`/`DELETE /api/admin/reports/schedules/{id}`, and `POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index a829a4f6df..00f4ad1414 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -622,19 +622,71 @@ describe('docsLinks', () => { expect(apiReference).not.toContain('Mobile Remote Access'); }); - it('documents POST as the only report transport that uses Pulse Assistant', () => { + it('keeps report narration behind both POST and the token spending scope', () => { const handler = readFileSync( path.join(repoRoot, 'internal', 'api', 'metrics_reporting_handlers.go'), 'utf8', ); - expect(handler).toContain( - 'if r.Method == http.MethodPost {\n\t\tnarrator, _, findings = h.resolveNarrator(r.Context())', + const single = handler + .split('func (h *ReportingHandlers) HandleGenerateReport(')[1] + .split('\nfunc ')[0]; + const fleet = handler + .split('func (h *ReportingHandlers) generateMultiReportFromBody(')[1] + .split('\nfunc ')[0]; + const caller = handler.split('func reportCallerMayUseAI(')[1].split('\nfunc ')[0]; + + // Whitespace is not the contract. Both permission checks and the separate + // deterministic paths are; behavioural Go tests exercise every caller. + expect(single).toMatch( + /case r\.Method == http\.MethodPost && reportCallerMayUseAI\(r\.Context\(\)\):\s*narrator, _, findings = h\.resolveNarrator\(r\.Context\(\)\)/, ); - expect(handler).toContain('req.Narrator = getReportNarrator{}'); + expect(single.match(/h\.resolveNarrator\(/g)).toHaveLength(1); + expect(single).toMatch( + /case r\.Method == http\.MethodPost:\s*findings = h\.resolveExistingFindings\(r\.Context\(\)\)\s*req\.Narrator = scopeLimitedReportNarrator\{\}/, + ); + expect(single).toMatch( + /default:\s*findings = h\.resolveExistingFindings\(r\.Context\(\)\)\s*req\.Narrator = getReportNarrator\{\}/, + ); + expect(caller).toContain('internalauth.GetAPIToken(ctx).(*config.APITokenRecord)'); + expect(caller).toMatch(/if !ok \|\| record == nil \{\s*return true/); + expect(caller).toContain('return record.HasScope(config.ScopeAIExecute)'); + expect(fleet).toMatch( + /if reportCallerMayUseAI\(ctx\) \{\s*_, fleetNarrator, findings = h\.resolveNarrator\(ctx\)/, + ); + expect(fleet.match(/h\.resolveNarrator\(/g)).toHaveLength(1); + expect(fleet).toMatch( + /\} else \{\s*(?:\/\/[^\n]*\n\s*)*findings = h\.resolveExistingFindings\(ctx\)/, + ); + }); + + it('ships report help that separates read permission from AI spending', () => { for (const guide of ['API.md', 'MSP.md']) { const rootDoc = readFileSync(path.join(repoRoot, 'docs', guide), 'utf8'); expect(readFileSync(path.join(frontendRoot, 'public', 'docs', guide), 'utf8')).toBe(rootDoc); expect(rootDoc).toContain('`POST /api/admin/reports/generate` ('); + // Parse the actual report section, not both entire reference guides: + // unrelated installation/API tables are not this narration contract. + const reportSection = + guide === 'API.md' + ? rootDoc + .split('### Advanced Reporting (Pro)')[1] + .split('### Queue and Dead-Letter Tools')[0] + : rootDoc.split('## Per-client reports')[1].split('## Licensing')[0]; + const rendered = document.createElement('div'); + rendered.innerHTML = renderDocMarkdown(reportSection, guide.replace('.md', '')); + const text = rendered.textContent?.replace(/\s+/g, ' '); + expect(text).toContain('Report generation uses settings:read.'); + expect(text).toContain( + 'For single-resource and fleet PDF narration, an API token also needs ai:execute (or full access).', + ); + expect(text).toContain( + 'Without it, the report still succeeds with a deterministic summary and no AI provider call', + ); + expect(text).toContain('a single-resource PDF also explains the missing scope.'); + expect(text).toContain( + 'Browser sessions and server-run schedules keep their existing narration behaviour.', + ); + expect(text).toContain('AI budgets still apply. GET and CSV never use AI narration'); } const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); expect(apiReference).toContain('it never calls an AI provider'); diff --git a/internal/api/report_ai_narration_scope_test.go b/internal/api/report_ai_narration_scope_test.go index fcaf60a289..6b8f7f83b5 100644 --- a/internal/api/report_ai_narration_scope_test.go +++ b/internal/api/report_ai_narration_scope_test.go @@ -100,6 +100,56 @@ func TestSingleReportAINarrationNeedsAIExecuteForTokens(t *testing.T) { } } +// Spending authority never changes GET's no-AI transport contract. +func TestSingleReportGETNeverUsesAIRegardlessOfTokenScopes(t *testing.T) { + for _, tc := range reportNarrationCallers { + t.Run(tc.name, func(t *testing.T) { + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + engine := &recordingReportEngine{ReportEngine: reporting.NewReportEngine(reporting.EngineConfig{MetricsStore: store})} + original := reporting.GetEngine() + reporting.SetEngine(engine) + t.Cleanup(func() { reporting.SetEngine(original) }) + + narrator := &countingReportNarrator{} + var narratorResolves, findingsResolves atomic.Int32 + handler := NewReportingHandlers(nil, nil) + handler.SetNarratorResolver(func(context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider) { + narratorResolves.Add(1) + return narrator, nil, nil + }) + handler.SetExistingFindingsResolver(func(context.Context) reporting.FindingsProvider { + findingsResolves.Add(1) + return nil + }) + + req := httptest.NewRequest(http.MethodGet, + "/api/admin/reports/generate?format=pdf&resourceType=node&resourceId=node-1", nil) + if caller := reportCaller(t, tc.scopes); caller != nil { + attachAPITokenRecord(req, caller) + } + rec := httptest.NewRecorder() + handler.HandleGenerateReport(rec, req) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/pdf" { + t.Fatalf("status %d, content type %q, body=%s", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()) + } + if narrator.calls.Load() != 0 || narratorResolves.Load() != 0 || findingsResolves.Load() != 1 { + t.Fatalf("GET narration calls=%d service resolves=%d existing findings=%d, want 0, 0, 1", narrator.calls.Load(), narratorResolves.Load(), findingsResolves.Load()) + } + narrative, err := engine.NarrativeFor(engine.requests[len(engine.requests)-1]) + if err != nil { + t.Fatalf("narrative: %v", err) + } + if narrative.Source != reporting.NarrativeSourceHeuristic || narrative.Disclaimer != reportGETNarrativeNote { + t.Fatalf("narrative = source %q disclaimer %q, want the deterministic summary with the GET note", narrative.Source, narrative.Disclaimer) + } + }) + } +} + type stubFleetNarrator struct{} func (stubFleetNarrator) NarrateFleet(context.Context, reporting.FleetNarrativeInput) (reporting.FleetNarrative, error) {