VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
Contract reference

The finding model

The two layers — raw and curated — the evidence record, and why nothing reaches a client unreviewed.


RawFinding         what a HARNESS emits. Permissive, passthrough, refuses curated fields.
       ↓ normaliseFinding() at the engine boundary
Finding            raw, immutable, one per detection per run. The audit trail.
       ↓ analyst review — a human, always
PublishedFinding   curated, client-visible, keyed by dedupeKey across all runs
                   of an Assessment. Analyst-editable.

The separation is what makes the hard invariant enforceable: nothing reaches a client before an analyst reviews it. A raw record has no review status at all — it cannot be "approved", because approval is a property of the curated layer.

Source: verifi-engine-contract/src/finding.ts.

Both layers at once

LAYER 1 — RAW · IMMUTABLE AUDIT TRAIL LAYER 2 — PUBLISHED · CLIENT-VISIBLE run 1 · Finding run 2 · Finding run 3 · Finding one per detection, per run, never edited schema is strict so a reviewStatus cannot be smuggled on group by dedupeKey asset · location · category classify against published_findings new · reopened · carried new · reopened REVIEW GATE a human decides pending_review PublishedFinding rawFindingRefs ≥ 1 reviewStatus carried — already decided in an earlier run; only lastTestedVersion and lastSeenRunId move no auto-approve · no publish-on-timeout publish() refuses the whole batch with a 409 if any new or reopened item has no decision
Nothing new reaches a client without a human deciding it should. Only carried items skip the gate, and that is not an auto-approval — it is a finding a person already ruled on in an earlier run, whose published record gains nothing but a fresh lastTestedVersion. Publishing refuses the entire batch if any new or reopened item is undecided. The six review statuses are pending_review, open, false_positive, accepted_risk, remediated and reopened — deliberately none meaning “auto-approved” or “published because review timed out”.

The emit layer — zRawFinding

What a harness produces, before the engine normalises it. Published in the contract since 1.4.0, and the type of ctx.emitFinding.

export const zRawFinding = z.object({
  findingKey: z.string().min(1).optional(),   // the stable cross-run identity
  title: z.string().min(1),                   // the only required field besides severity
  description: z.string().optional(),
  severity: zRawSeverity,                     // a level, or { label } + anything else
  impact: z.string().optional(),
  remediation: z.string().optional(),
  cwe: z.union([z.string(), z.array(z.string())]).optional(),
  cves: z.array(z.string()).optional(),       // ⚠️ PLURAL
  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()
  .refine(/* no reviewStatus | publishedAt | reviewedBy | clientVisible */);

Three deliberate asymmetries with the stored zFinding below:

zRawFinding (emit)zFinding (stored)
unknown keyscarried (.passthrough())rejected (.strict())
CVEscves: string[]cve?: string — the first one
CWEstring | string[]cwe?: string — the first one

.passthrough() on the emit layer is not laxness. .strict() there would reject payloads the engine happily stores, making the schema a stricter authority than the system it describes — a different way to be wrong, not a safer one. .strict() on the stored layer is what stops a reviewStatus reaching the immutable record.

Full author-facing detail, including what the normaliser does with each field: Findings and events.

The raw layer

export const zFinding = z.object({
  id: z.string().min(1),
  runId: z.string().min(1),
  binding: zRunBinding,
  /** Stable identity across runs — see computeDedupeKey. */
  dedupeKey: z.string().min(1),
  title: z.string().min(1),
  severity: zSeverity,
  confidence: zConfidence,
  category: z.string().min(1),
  description: z.string().min(1),
  impact: z.string().optional(),
  remediation: z.string().optional(),
  cwe: z.string().optional(),
  cve: z.string().optional(),
  cvss: z.object({ score: z.number().min(0).max(10), vector: z.string() }).optional(),
  location: zFindingLocation.optional(),
  reproSteps: z.array(z.string()).default([]),
  references: z.array(z.string()).default([]),
  evidenceIds: z.array(z.string()).default([]),
  detectedAt: zISO,
}).strict();   // .strict() so a reviewStatus cannot be smuggled onto the immutable layer

Written the moment it is produced, never at finalize. Finding persistence and run outcome are independent.

Note cwe and cve are singular strings here, while what a harness emits uses cwe?: string[] | string and cves?: string[]. The engine collapses arrays to their first element at the boundary. See Findings and events.

reproSteps, references and evidenceIds are always [] on the normalisation path — there is currently no way for a harness to populate them.

zFindingLocation

export const zFindingLocation = z.object({
  url: z.string().url().optional(),
  host: z.string().optional(),
  /** Kept explicitly. v1 stripped ports and rescanned :8443 as :443. */
  port: z.number().int().positive().optional(),
  param: z.string().optional(),
  method: z.string().optional(),
}).strict();

url must be a valid URL, so a bare hostname fails. The engine prefixes a non-http-prefixed affected[0].url with https:// and also copies the original to host, which is why emitting { url: 'api.example.com' } works — but emitting a valid URL yourself is clearer.

There is no path from a harness to port, method, or cvss through the current normalisation. See Known gaps.

computeDedupeKey

export function computeDedupeKey(assetId: string, location: string, category: string): string {
  return createHash('sha256')
    .update(`${assetId} ${location} ${category.toLowerCase()}`)
    .digest('hex');
}

Stable across runs, which is what lets a retest attach to an existing PublishedFinding rather than creating a duplicate. Category is lowercased so a casing difference cannot split one cluster into two — the same class of bug as v1's separate "Critical" and "critical" severity buckets.

zEvidence

export const zEvidenceKind = z.enum(['http_transcript', 'screenshot', 'pcap', 'tool_output', 'log']);

export const zEvidence = z.object({
  id: z.string().min(1),
  runId: z.string().min(1),
  kind: zEvidenceKind,
  /** Content-addressed, so immutability is verifiable rather than asserted. */
  sha256: z.string().regex(/^[0-9a-f]{64}$/, 'must be a sha256 hex digest'),
  sizeBytes: z.number().int().nonnegative(),
  storageRef: z.string().min(1),
  /** Redaction is applied once at the pipeline boundary, not per call site. */
  redacted: z.boolean(),
  capturedAt: zISO,
}).strict();

The curated layer

export const zReviewStatus = z.enum([
  'pending_review', 'open', 'false_positive', 'accepted_risk', 'remediated', 'reopened',
]);

There is deliberately no state meaning "auto-approved" or "published on timeout". The hard invariant admits no escape hatch, so the enum admits no such value.

export const zPublishedFinding = z.object({
  id: z.string().min(1),
  assessmentId: z.string().min(1),
  dedupeKey: z.string().min(1),
  reviewStatus: zReviewStatus,
  /** Every raw observation that matched this key. Never empty — derived, not invented. */
  rawFindingRefs: z.array(z.string().min(1)).min(1),

  // curated, analyst-editable, client-facing
  title: z.string().min(1),
  severity: zSeverity,
  confidence: zConfidence,
  category: z.string().min(1),
  description: z.string().min(1),
  impact: z.string().optional(),
  remediation: z.string().optional(),
  references: z.array(z.string()).default([]),
  location: zFindingLocation.optional(),

  // client-facing lifecycle
  introducedVersion: z.number().int().min(1),
  lastTestedVersion: z.number().int().min(1),
  // audit lifecycle
  firstSeenRunId: z.string().min(1),
  lastSeenRunId: z.string().min(1),

  publishedAt: zISO.optional(),
  reviewedBy: z.string().optional(),
  auditLog: z.array(zReviewEvent).default([]),
}).strict()
  .refine((f) => f.lastTestedVersion >= f.introducedVersion, …);

rawFindingRefs has .min(1): a published record is derived, not invented. There is no way to create one that no run produced.

Both lifecycle keyings are required because they answer different questions:

KeyingQuestion it answers
introducedVersion / lastTestedVersionClient-facing. What the report says; what "reopened in v3" means.
firstSeenRunId / lastSeenRunIdAudit. Which exact execution produced the evidence.

Dropping either loses a question the platform needs answerable.

export const zReviewEvent = z.object({
  at: zISO,
  by: z.string().min(1),
  action: z.enum(['approve', 'edit', 'reject', 'defer', 'reopen', 'accept_risk', 'mark_remediated']),
  note: z.string().optional(),
}).strict();

What this means for a harness author

You never write to the curated layer, and attempting to is an error rather than a silent drop. Practically:

  • Emit observations, not verdicts. confidence: 'firm' means evidenced; confidence: 'confirmed' should mean a human or an exploit confirmed it. The ASM harness uses firm for everything its deterministic rubric produces, with the comment "evidenced, not human-confirmed".
  • Do not soften severity to avoid noise. An analyst downgrades; you report.
  • Write description, impact and remediation properly. The analyst edits the curated copy, but they start from yours, and a bare tool string is not a starting point.
  • Keep dedupeKey stable. It is the join between your raw record and the curated one an analyst has already reviewed. Changing it makes a known, accepted-risk finding reappear as new.