VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
Harness authoring

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:

cvesPLURAL. A singular cve is carried through by .passthrough() and never read.
findingKeyNot dedupeKey. The engine derives the dedupe key from it.
affectedAn array of objects { url, parameter }, not a bare url string.
cweA 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  ·  clientVisible

All 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.

FieldRule
titleTrimmed. Empty or absent throws a finding must have a title.
severityString 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.
confidenceDefaults to 'firm'. Must end up one of confirmed, firm, tentative or the final schema parse fails.
categorycategory ?? sourceTool ?? source ?? 'unknown'.
descriptionDefaults to title.
impact, remediationCopied through when present. Both optional.
cweArray → first element; string → as-is.
cveRead 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.
idfind_<runId>_<seq padded to 4> — assigned by the engine, idempotent under replay.
reproSteps, references, evidenceIdsAlways []. 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 harness

Harnesses 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 detail is 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:

TypeSeverityEmitted by
tool_callinfoMcpClient on a successful call
tool_errorwarnMcpClient on any failure
mcp.fallback_usedwarnFailoverSlot, once per slot per run
mcp.no_healthy_toolerrorFailoverSlot, once per slot
model_callinfoModelClient on success
model_errorwarnModelClient on failure
model_price_unknownwarnModelClient, once, for an unpriced model
threshold_breachwarnrunHarness, via the ledger's onBreach
phase_start / phase_endinforunHarness, dry-run path only

Emit phase_start / phase_end yourself on the real path — the SDK only does it during a dry run.