Findings and events
What emitFinding actually accepts, how the engine normalises it, how dedupeKey stays stable across runs, and the event vocabulary.
Emit as you produce
import type { RawFinding } from '@pragyacyber/engine-contract';
emitFinding(f: RawFinding): void;Synchronous, fire-and-forget, and it streams to the engine immediately. Do not collect results and return them at the end.
The rule comes from a specific failure: the previous engine made _finalize() the only
caller of persist_findings, so a failure in a later phase gave the tenant nothing at all
from a scan whose results were verified on disk. The engine now persists each item the
moment it arrives, which means a run that is cancelled, times out, or crashes keeps
everything it discovered up to that point.
The production ASM harness streams after every phase and tracks a stable key so an item whose severity is later upgraded in the graph is not re-sent:
const emittedKeys = new Set<string>();
const streamResults = (phase: string, items: GraphItem[]) => {
for (const gf of items) {
const raw = toRawItem(gf);
if (!raw || !raw.findingKey) continue;
if (emittedKeys.has(raw.findingKey)) continue; // already streamed in an earlier phase
emittedKeys.add(raw.findingKey);
ctx.emitFinding(raw);
}
};zRawFinding — the shape emitFinding accepts
The schema is published in @pragyacyber/engine-contract as zRawFinding, and
ctx.emitFinding is typed with its inferred type. Import it if you want to validate a
mapper's output in your own tests.
export const zRawFinding = z
.object({
/** Stable identity across runs. The engine derives the dedupeKey FROM this. */
findingKey: z.string().min(1).optional(),
title: z.string().min(1),
description: z.string().optional(),
severity: zRawSeverity, // a level, or { label } + anything else
impact: z.string().optional(),
remediation: z.string().optional(),
/** A string or an array; the engine keeps the first. */
cwe: z.union([z.string(), z.array(z.string())]).optional(),
/** ⚠️ PLURAL. A singular `cve` is silently ignored. */
cves: z.array(z.string()).optional(),
affected: z.array(zRawAffected).optional(), // [{ url?, parameter?: string | null }]
sourceTool: z.string().optional(),
source: z.string().optional(),
category: z.string().optional(),
confidence: zConfidence.optional(),
})
.passthrough() // extra keys are CARRIED, not rejected
.refine(/* no reviewStatus | publishedAt | reviewedBy | clientVisible */);Four field names are not guessable and used to fail silently, because the normaliser reads what it recognises and ignores the rest:
cves | PLURAL. A singular cve is carried through by .passthrough() and never read. |
findingKey | Not dedupeKey. The engine derives the dedupe key from it. |
affected | An array of objects { url, parameter }, not a bare url string. |
cwe | A string or an array; only the first element is kept. |
.passthrough(), not .strict()
Extra keys are allowed and carried through — a harness may attach whatever evidence it
has, and the engine keeps what it recognises. Making this schema .strict() would reject
payloads the engine happily stores, which would make the schema a stricter authority than
the system it describes. Verified: { title, severity, rawOutput: {...} } parses.
The same reasoning applies one level down, on severity. zRawSeverity accepts either a
bare level or { label } plus any other keys, because a recon harness legitimately
carries cvssScore: null alongside the label — deliberately, since a fabricated CVSS on a
missing header looks authoritative and is worse than no score at all.
What a harness may NOT send
reviewStatus · publishedAt · reviewedBy · clientVisibleAll four are refused, by the schema and again by the engine. A harness cannot pre-approve its own output; that requires a named analyst and a separate write. They are refused loudly rather than dropped, because silently discarding one leaves the author believing it took effect.
A worked example, in the shape the ASM harness actually emits:
ctx.emitFinding({
findingKey: 'missing-header:strict-transport-security:api.example.com',
title: 'missing header: strict-transport-security',
severity: 'medium',
confidence: 'firm',
category: 'http',
description: 'The response carries no Strict-Transport-Security header, so a browser will make a first request over plaintext HTTP.',
impact: 'A network attacker can downgrade the initial request and intercept the session.',
remediation: 'Set Strict-Transport-Security with a max-age of at least 31536000 on every HTTPS response.',
cwe: 'CWE-319',
affected: [{ url: 'https://api.example.com' }],
});What the engine does with it
normaliseFinding() runs at the boundary, once, rather than in each harness — so an author
getting a severity casing wrong cannot split one cluster into two.
| Field | Rule |
|---|---|
title | Trimmed. Empty or absent throws a finding must have a title. |
severity | String or { label }. Lowercased and trimmed. Must be one of critical, high, medium, low, info — otherwise throws. Refused rather than defaulted, because something silently downgraded is something nobody will ever act on. |
confidence | Defaults to 'firm'. Must end up one of confirmed, firm, tentative or the final schema parse fails. |
category | category ?? sourceTool ?? source ?? 'unknown'. |
description | Defaults to title. |
impact, remediation | Copied through when present. Both optional. |
cwe | Array → first element; string → as-is. |
cve | Read from cves[0]. A singular cve key is ignored. |
affected[0] | Becomes location. A url not starting with http is prefixed https:// and also copied to location.host. parameter becomes location.param. |
id | find_<runId>_<seq padded to 4> — assigned by the engine, idempotent under replay. |
reproSteps, references, evidenceIds | Always []. There is currently no way for a harness to populate them. |
Severity: info, and only info
The allow-list is now derived from the contract (new Set(zSeverity.options)) rather
than restated, so the two cannot drift again. Emitting severity: 'info' works. Verified
against contract 1.5.1: zSeverity.safeParse('info') succeeds and
safeParse('informational') fails, and zRawFinding agrees with both.
Near-miss spellings are refused, not aliased — taking both would recreate exactly the
split the normalisation exists to prevent (v1 carried separate Critical and critical
buckets for one issue). What you get instead is an error naming the value to use:
unrecognised severity 'informational' on "Missing HSTS" — use 'info'
unrecognised severity 'information' on "…" — use 'info'
unrecognised severity 'warning' on "…" — use 'medium'Anything else gets the full list: expected one of critical, high, medium, low, info.
Whether you should emit info is a separate question from whether you can. The ASM
harness treats informational output as inventory and does not emit it, by design — it is
surface data, not a finding. Emit info for something a reader should know and act on at
low priority, not for everything a tool printed.
Curated fields are refused, not dropped
const CURATED_ONLY = ['reviewStatus', 'publishedAt', 'reviewedBy', 'clientVisible'];Emitting any of these throws:
raw findings cannot carry curated-layer fields (reviewStatus) — review status is set
by an analyst, never by a harnessHarnesses are trusted code, so this is not a security boundary. It is a correctness one: dropping the field silently would mean the author never learns that approval is not theirs to grant. Raw and curated are separate layers; see The finding model.
dedupeKey — stability across runs
export function computeDedupeKey(assetId: string, location: string, category: string): string {
return createHash('sha256')
.update(`${assetId} ${location} ${category.toLowerCase()}`)
.digest('hex');
}The engine derives the key one of two ways:
const dedupeKey = raw.findingKey
? computeDedupeKey(assetId, String(raw.findingKey), category)
: computeDedupeKey(assetId, raw.affected?.[0]?.url ?? '', category);This is what lets a retest attach to an existing curated record instead of creating a
duplicate, so it must not vary run to run. category is lowercased inside the hash so
a casing difference cannot split one cluster into two — verified: computeDedupeKey(a, l, 'DNS')
and computeDedupeKey(a, l, 'dns') produce the same digest.
Rules for a good findingKey:
- Deterministic. No timestamps, no run ids, no scan counters, no random ids.
- Per-asset. "Legacy TLS on 20 hosts" should stream as 20 items the engine can group, each durable the instant its phase ends — not one row known only at the very end.
- Not severity-dependent. The ASM harness deliberately excludes severity from its key, because an item upgraded from info to high in a later phase would otherwise look new and be emitted twice.
- Stable under re-titling. If you later improve a title, keep the key. The ASM harness keeps the tool's own words as the title precisely so it does not break keys other runs already use.
A workable shape: <check-id>:<asset> — for example
missing-header:strict-transport-security:api.example.com.
Omitting findingKey is legal; the key then derives from affected[0].url, which is
stable only if the URL is.
Enrich at emit time, not in the report
The ASM harness learned this the expensive way. Its mapper originally set neither impact
nor remediation and let description fall back to title, so everything the engine had
ever stored from ASM read as a bare tool string — the same text as title and description,
zero impact, zero remediation.
The fix was to apply a static, auditable catalogue at emit time rather than in the report, so the console, the API and every report get the same enriched record from one source of truth. Two rules came out of it worth copying:
- Enrichment never overwrites what the scan produced. A tool that wrote real prose keeps it; the catalogue only fills the gap.
- Test for prose, do not assume it. A scanner's
detailis sometimes its own rule id (config-json-exposure-fuzz), and treating that as a description silently beats good catalogue prose. The ASM guard requires at least 40 characters and 6 words.
Events
emitEvent(e: { type: string; severity?: string; payload?: Record<string, unknown> }): void;
log(msg: string, extra?: Record<string, unknown>): void;severity defaults to 'info', payload to {}. log() emits
{ type: 'log', severity: 'info', payload: { msg, ...extra } }.
The contract defines a closed zEventType enum, and emitEvent's type is
EventType | string — so the enum autocompletes, but a novel string still compiles. Staying
inside it is what makes your events legible to the console:
phase_start · phase_end · agent_spawn · agent_reap · tool_call · tool_result ·
finding · heartbeat · approval_request · approval_decision · threshold_breach ·
scope_expansion · coverage_gap · error · log · tool_error · mcp.fallback_used ·
mcp.no_healthy_tool · model_call · model_error · model_price_unknown
Twenty-one members as of contract 1.5.1.
Events the SDK emits for you, which you should not duplicate:
| Type | Severity | Emitted by |
|---|---|---|
tool_call | info | McpClient on a successful call |
tool_error | warn | McpClient on any failure |
mcp.fallback_used | warn | FailoverSlot, once per slot per run |
mcp.no_healthy_tool | error | FailoverSlot, once per slot |
model_call | info | ModelClient on success |
model_error | warn | ModelClient on failure |
model_price_unknown | warn | ModelClient, once, for an unpriced model |
threshold_breach | warn | runHarness, via the ledger's onBreach |
phase_start / phase_end | info | runHarness, dry-run path only |
Emit phase_start / phase_end yourself on the real path — the SDK only does it during a
dry run.