Waiver Validity#
WaiverValidity is a Surface-side derived projection that gives Flow (and any
other Hachure-consuming client) a stable, versioned answer to "is this
assumed claim's accepted-gap waiver valid" — without re-parsing the
free-form claim.metadata.waiver object itself.
WaiverValidity is additive and sibling to TrustStatus. It never changes
core status derivation: deriveTrustStatus/deriveClaimStatus and
statusFunctionVersion are untouched by this projection. An assumed claim
stays assumed whether or not a waiver is attached — waiver validity is a
downstream consumer concern, not a status-derivation input.
Origin#
Surface's own schemas (schemas/claim.schema.json,
schemas/verification-event.schema.json) carry no waiver vocabulary. The
shape documented here is the de facto convention already stamped by
kontourai/flow-agents: ADR 0020 §3 has flow-agents' record-evidence/
record-gate-claim write claim.metadata.waiver = {reason, approved_by, approved_at} onto the existing, untyped claim.metadata object, force the
claim's status to the existing assumed value, and print a loud WAIVED:
line. Prior to this projection, trust-reconcile.js only checked presence
of those three keys — it never validated them — which is why
kontourai/flow-agents#511 narrowed Builder gates to verified-only: Flow had
no canonical, versioned predicate to trust instead of re-parsing free-form
metadata itself. deriveWaiverValidity is that predicate.
The claim.metadata.waiver wire shape#
No schema change was required to read this: Claim.metadata is already
{"type":"object"}, unconstrained, in the vendored schemas/claim.schema.json.
The wire field names are exactly the ones ADR 0020 §3 already produces —
Surface reads them verbatim and does not rename them on the way in:
// claim.metadata.waiver
{
"reason": "string, non-empty",
"approved_by": "string, non-empty",
"approved_at": "string, strict RFC3339 date-time: YYYY-MM-DDThh:mm:ss[.sss](Z|±hh:mm), real calendar date"
}
reason— free-text justification for the waiver.approved_by— free-text identifier of the approver. This is not authenticated (see Residual below).approved_at— must match the exact grammarYYYY-MM-DDThh:mm:ss(.sss)?(Z|±hh:mm)(the RFC 3339date-timeproduction, fractional seconds optional) with a real calendar date (e.g.2026-02-30is rejected even though it matches the grammar shape). This is a genuine strict RFC 3339 parser — notDate.parse(which accepts a much wider, engine-defined grammar including bare years, space-separated dates, and silently-rolled-over invalid calendar dates) — so acceptance is deterministic across JS engines. The numeric UTC offset (±hh:mm), when used instead ofZ/z, is also range-checked: offset-hour00-23, offset-minute00-59(e.g.+25:00and+00:99are rejected). Leap-second policy: this parser validates RFC 3339 grammar, not leap-second placement — a:60seconds value is accepted at any minute, not only at an actual leap-second instant (23:59:60Z); this is a deliberate, disclosed scope boundary, not an oversight.
Source: kontourai/flow-agents ADR 0020 §3, parseWaiver in
workflow-sidecar.ts; the gap this projection closes is described in
kontourai/flow-agents#511.
WaiverFacts#
WaiverValidity.waiver, when present, echoes the raw snake_case wire values
Surface read plus camelCase aliases for TS-idiomatic consumption. Surface does
not invent new field names for the wire values — reason stays reason;
only the approver/timestamp fields get a camelCase echo:
export interface WaiverFacts {
reason?: string;
approvedBy?: string;
approvedAt?: string;
}
WaiverVerdict#
export type WaiverVerdict =
| "not-applicable"
| "bare-assumed"
| "complete-waiver"
| "incomplete-waiver"
| "stale-or-revoked-waiver"
| "command-backed-waiver-rejection";
| Verdict | Meaning |
|---|---|
not-applicable |
The claim's derived status is neither assumed nor a waiver-bearing stale/revoked. There is nothing for waiver validity to say about this claim. |
bare-assumed |
The claim's derived status is assumed and claim.metadata.waiver is absent, or present with the JS-only value undefined (JSON cannot encode undefined, so this only matters for direct deriveWaiverValidity callers, not JSON-bundle producers). A bare assumed claim is never acceptable by default — this verdict exists so a consumer cannot mistake "no waiver" for "waived." |
complete-waiver |
The claim's derived status is assumed, claim.metadata.waiver is present, and reason/approved_by/approved_at all pass shape validation (non-empty strings, a strict RFC3339 date-time). Note: approverAuthenticated is still false on this verdict — "complete" describes shape completeness, not identity trust. |
incomplete-waiver |
The claim's derived status is assumed, claim.metadata.waiver is present, but one or more of reason/approved_by/approved_at fails shape validation (missing, empty, or malformed), including claim.metadata.waiver: null (a present-but-null value is malformed, not absent). incompleteFields names exactly which wire key(s) failed. A non-object waiver value lists all three fields as incomplete. |
stale-or-revoked-waiver |
The claim's derived status is stale or revoked and claim.metadata.waiver is still present on it — a waiver that was stamped for a status that has since moved past assumed. This is inferred from whatever metadata.waiver happens to still be attached; if a producer strips metadata.waiver when a claim goes stale/revoked, that history is invisible to Surface. |
command-backed-waiver-rejection |
The claim's derived status is assumed, evidence for the claim includes command-backed evidence (isCommandBackedEvidence), and claim.metadata.waiver is present. Per ADR 0020 §3, "a command-backed check cannot be waived" — this verdict makes that divergence rule natively derivable in Surface instead of requiring flow-agents to re-implement it. |
Precedence#
deriveWaiverValidity evaluates in this order; the first match wins:
- Status is
assumed, evidence includes command-backed evidence, and a waiver is present →command-backed-waiver-rejection. This wins even over an otherwise-complete waiver — a command-backed check cannot be waived regardless of how complete the waiver's shape is. - Status is
staleorrevokedand a waiver is present →stale-or-revoked-waiver. - Status is anything else not covered by step 2 →
not-applicable. - Status is
assumed, no waiver present →bare-assumed. A bareassumedclaim is never acceptable by default; there is no verdict that implicitly treats "no waiver" as passing. - Status is
assumed, a waiver is present, but shape validation fails on one or more fields →incomplete-waiver. - Otherwise →
complete-waiver.
isCommandBackedEvidence infers "command-backed" from
evidenceType === "test_output" or the presence of evidence.execution. A
producer can evade this heuristic the same way ADR 0020's own
"evidenceType-laundering route" residual describes (omitting execution,
mislabeling evidenceType). This projection inherits that upstream residual;
it does not solve it.
Residual: approverAuthenticated is always false#
export interface WaiverValidity {
verdict: WaiverVerdict;
approverAuthenticated: false;
waiver?: WaiverFacts;
incompleteFields?: Array<"reason" | "approved_by" | "approved_at">;
}
approverAuthenticated is the literal false on every verdict branch,
including complete-waiver — never omitted, never computed as true. This is
by design, not a TODO: approved_by is free text, never cryptographically
bound to an identity. ADR 0020 discloses this as its own residual
("Authority-binding residual" / a careless or hostile producer can stamp any
string as approver). deriveWaiverValidity validates the shape of the
waiver only — never the approver's identity — so a complete-waiver verdict
means "the three fields are present and well-formed," not "this waiver was
granted by someone we verified."
This field exists precisely so that residual can never be silently folded
into "complete-waiver ⇒ trusted." A consumer that wants stronger identity
binding needs the Assurance profile (L1/L2 identity binding) or an equivalent
authentication mechanism layered on top — deriveWaiverValidity does not
provide one.
Versioning#
export const waiverValidityFunctionVersion = "1";
Mirrors the existing statusFunctionVersion convention (src/status.ts,
ADR 0003 step 2): increment this when the derivation algorithm changes so
that stored results can be identified and re-evaluated if needed.
Consumption paths#
There are two supported ways to consume WaiverValidity. Per
flow-agents/scripts/ci/derive-claim-status.mjs (which already imports
@kontourai/surface's deriveClaimStatus directly, not a JSON-schema report
field), the direct function import is the proven path for how Flow consumes
Surface today.
1. Direct function import (primary)#
import { deriveWaiverValidity } from "@kontourai/surface";
const result = deriveWaiverValidity({ claim, status, evidence });
2. report.waiverValidityByClaimId#
buildTrustReport attaches a per-claim map to TrustReport, mirroring the
existing evidenceRequirementsByClaimId sibling-map pattern. This also
round-trips through the surface report CLI's JSON output:
surface report --input examples/surface-example-bundle.json
interface TrustReport {
// ...
waiverValidityByClaimId: Record<string, WaiverValidity>;
waiverValidityFunctionVersion: string;
}
These fields are declared in the vendored trust-report-waivers.schema.json
extension schema (Hachure ≥ 0.12.0); see Schemas
for how a strict consumer validates a waiver-bearing report against the
extension while the neutral core trust-report.schema.json stays
field-minimal.
Non-goals#
- Does not widen Builder gates. Landing
WaiverValidityin Surface does not by itself change Builder gate behavior. Per the issue this projection responds to, "Builder may widen beyond verified-only only after this contract and Flow matching land" — that is a hard non-goal boundary for this projection. - Flow-side matching lands separately. Wiring
deriveWaiverValidityintotrust-reconcile.js(flow-agents) is explicitly out of scope here and is a follow-up change in thekontourai/flow-agentsrepo, not this one. - Does not authenticate approvers. See
Residual above —
approved_bystays free-text and unauthenticated by design.