Integrate reviewed report narration permission guidance

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-11 19:53:57 +01:00
9 changed files with 281 additions and 12 deletions
+10 -1
View File
@@ -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.
+9 -3
View File
@@ -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
@@ -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.
@@ -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)
@@ -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; });
+10 -1
View File
@@ -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.
+9 -3
View File
@@ -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
@@ -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');
@@ -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) {