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

Service, TestInput, Run

The composition an admin builds, the inputs for one execution, the manifest your container receives, and the run's own record.


Service            the admin composition: harness + models + slots + KBs
  └─ TestInput     the inputs for ONE execution
       └─ RunManifest   immutable, fully resolved, injected into your container
            └─ Run      the execution's own record

Sources: verifi-engine-contract/src/service.ts, run.ts, domain.ts.

Declare, bind, freeze

AUTHORING TIME Harness declares what it needs slots.required[{ name, kind }] models[{ name, tier, req }] phases[{ key, label, order }] inputs{ … } COMPOSITION TIME Service supplies them, by name mcpBindings{ slot → primary, fallback? } modelBindings{ role → modelId } kbBindings[{ kbId, accessMode }] SUBMIT TIME RunManifest freezes what was used composition.harnessDigest composition.mcpDigests{ … } slotFills{ slot → primary, fallback? } binds by name pins digests CAUGHT AT SERVICE PUBLISH a required slot or model role left unbound blocks status: 'published' CAUGHT AT FIRST USE, NOT AT STARTUP SlotNotFilledError · ModelRoleNotBoundError thrown at the first ctx.mcp() / ctx.model()
Three objects, three different moments, and the names are the only thing that connects them. The harness never names a vendor, an endpoint or a model id — it names a slot and a role, and the service decides what fills them. That indirection is what lets one harness run against a different MCP per tenant, and it is why the run’s frozen digests are the only honest record of what a given result was actually produced by.

zService

export const zService = z.object({
  id: z.string().min(1),
  name: z.string().min(1),
  harnessId: z.string().min(1),
  harnessSemver: zSemVer,

  /** role name → Model id. The same Model may fill several roles. */
  modelBindings: z.record(z.string().min(1), z.string().min(1)),
  /** slot name → { primary, fallback? } */
  mcpBindings: z.record(z.string().min(1), zSlotBinding),
  kbBindings: z.array(zKbBinding).default([]),

  /** Copied from the Harness at compose time so publish validation is self-contained. */
  requiredSlots: z.array(z.string().min(1)).default([]),
  requiredModelRoles: z.array(z.string().min(1)).default([]),

  defaultInputs: z.record(z.string(), z.unknown()).default({}),
  status: z.enum(['draft', 'published', 'deprecated']),
  semverCompat: zSemVer,
  version: z.number().int().positive(),
}).strict();

Validation gates publish, not draft. A half-composed draft is a normal state; a published Service with an unbound required slot is not. On publish:

  • every requiredSlots entry must have a primary binding, else required slot 'kali' has no primary binding — cannot publish;
  • every requiredModelRoles entry must be bound, else required model role 'planner' is unbound — cannot publish.

And at any status, a KB binding with accessMode: 'read_write' is refused:

a Service may not bind read_write — a Harness gets read_only or propose only

zCompositionSnapshot

The frozen copy captured into every manifest.

export const zCompositionSnapshot = z.object({
  serviceId: z.string().min(1),
  serviceVersion: z.number().int().positive(),
  harnessDigest: z.string().regex(/^sha256:[0-9a-f]+$/, 'must be a digest, not a tag'),
  mcpDigests: z.record(z.string(), z.string().regex(/^sha256:[0-9a-f]+$/, 'must be a digest, not a tag')),
  modelIds: z.record(z.string(), z.string().min(1)),
  kbContentVersions: z.record(z.string(), z.string().min(1)),
  capturedAt: zISO,
}).strict();

Digests, not tags. "What exactly ran" has to be answerable months later for audit, and a tag is a moving target.

modelIds is the map ctx.model(role) resolves against. If a role is absent, ModelRoleNotBoundError.

zTestInput

export const zTestInput = z.object({
  id: z.string().min(1),
  serviceId: z.string().min(1),
  binding: zRunBinding,
  target: zTargetScope,
  parameters: z.record(z.string(), z.unknown()).default({}),
  constraints: z.object({ budget: zBudget, maxConcurrency: z.number().int().positive() }).strict(),
  policy: zRunPolicy,
  metadata: z.object({
    requestedBy: z.string().min(1),
    reason: z.string().min(1),
    ticketRef: z.string().optional(),
  }).strict(),
}).strict();

metadata.reason is required and non-empty. Every run records why it was asked for.

zTargetScope

export const zTargetScope = z.object({
  scope: z.array(z.string().url()).min(1, 'a run needs at least one in-scope target'),
  excludes: z.array(z.string().url()).default([]),
  authMode: z.enum(['none', 'credentialed', 'session_token']),
  /** Target credentials, by indirection only. Resolved into the manifest in flight. */
  credentialRef: zCredRef.optional(),
}).strict();

Scope entries are full URLs, parsed as URLs.

zRunPolicy

export const zRunPolicy = z.object({
  /** Default ON. Redaction is a property of the pipeline, applied once at the boundary. */
  redaction: z.enum(['off', 'standard', 'strict']).default('standard'),
  hitl: z.enum(['auto', 'approve_destructive', 'approve_all']),
  /** Rules of engagement. `deniedActions` wins over `allowedActions`. */
  allowedActions: z.array(z.string()).default([]),
  deniedActions: z.array(z.string()).default([]),
}).strict();

This arrives on your context as ctx.policy. Nothing in the SDK enforces itallowedActions / deniedActions are free strings the SDK never reads. If your harness takes actions that a rule of engagement could forbid, you must honour it yourself.

zRunManifest

The immutable, fully-resolved packet injected into your container.

export const zRunManifest = z.object({
  runId: z.string().min(1),
  correlationId: z.string().min(1),
  binding: zRunBinding,
  target: zTargetScope,
  parameters: z.record(z.string(), z.unknown()).default({}),
  composition: zCompositionSnapshot,
  budget: zBudget,
  policy: zRunPolicy,
  /** slot name → resolved endpoint, filled by the Provisioner. */
  endpoints: z.record(z.string(), z.string()).default({}),
  /** slot name → { primary, fallback? } */
  slotFills: z.record(z.string(), zSlotFill).optional(),
  credentialRefs: z.record(z.string(), zCredRef).default({}),
  /** The one hard bound. A run cannot outlive this. */
  hardDeadline: zISO,
  /** Checked at handshake; MAJOR only. */
  contractVersion: zSemVer,
}).strict();

Two slot maps exist because endpoints can only express one server per slot, which made failover unrepresentable — a dead primary meant every tool call failed and the run merely produced fewer findings. slotFills is optional, and endpoints remains the fallback source, so an older Provisioner that fills only endpoints keeps working.

ctx.mcp() reads slotFills[slot] first, then endpoints[slot].

What your context sees:

Manifest fieldOn HarnessContext
runId, correlationId, binding, policysame names
parametersctx.input
budgetwrapped as ctx.budget (a BudgetLedger)
targetctx.target — readonly
hardDeadlinectx.hardDeadline — readonly, ISO-8601
slotFills / endpointsbehind ctx.mcp(slot)
composition.modelIdsbehind ctx.model(role)
credentialRefs, compositionnot exposed

credentialRefs remains unexposed, and that is coherent: a credRef is an indirection the CredentialBroker resolves at injection time, so a harness holding the reference could do nothing with it. What it needs arrives resolved, in the environment or in an input field.

zRun

export const zRunStatus = z.enum([
  'queued', 'provisioning', 'running', 'paused_hitl', 'stopping',
  'done', 'failed', 'canceled', 'timed_out',
]);

There is no budget_exceeded. Cost was removed as a terminal condition because a cost trip mid-scan severs the assessment and ships a client a partial report. timed_out is the bound that remains, and since spend accrues at a finite rate it bounds cost too.

export const zRun = z.object({
  id: z.string().min(1),
  correlationId: z.string().min(1),
  serviceId: z.string().min(1),
  binding: zRunBinding,
  status: zRunStatus,
  phase: z.string().optional(),
  startedAt: zISO.optional(),
  endedAt: zISO.optional(),
  hardDeadline: zISO,
  findingsCount: z.number().int().nonnegative().default(0),
  spend: zSpend,
  breaches: z.array(zThresholdBreach).default([]),
  exitReason: z.string().min(1).optional(),
}).strict()
  .refine((r) => !TERMINAL.has(r.status) || Boolean(r.exitReason), {
    message: 'a terminal Run must record why it ended',
  });

Terminal is done | failed | canceled | timed_out, and every one of them must carry an exitReason — v1 marked a no-op job done and stranded another in queued forever.

Domain objects

export const zRunBinding = z.object({
  tenantId: z.string().min(1),
  assessmentId: z.string().min(1),
  assetId: z.string().min(1),
}).strict();

The "who is this for" that every finding inherits. Resolved server-side with an uncached read before a manifest is built — a write-path guard must never see a cached null.

export const zAssessmentType = z.enum(['web_vapt', 'infra', 'cspm', 'osint']);
export const zAssessmentStatus = z.enum(['scanning', 'scan_complete', 'in_review', 'published']);

An Assessment is one type of testing against one asset, carrying a version history. A retest adds a version; it does not create a new Assessment. currentVersion bumps only on analyst publish, never on scan completion — so there is no v0, and an abandoned run leaves no hole in the sequence.

in_review is the gate, not a formality. There is no status that skips it and no timeout that publishes anyway.