Quick Verify
May 5, 2026 · View on GitHub
Spec id: quick-verify-spec version: 1.2.1
Source: plan Appendix A (canonical).
A.1 CLI grammar
Tokens: agentskeptic quick --input <path> (--postgres-url <url> | --db <sqlitePath>) --export-registry <path> with optional --emit-events <path> and --workflow-id <id> (default quick-verify). - input = stdin. Missing flag or both DB flags = phase A.
A.2 Phase A / B
- Phase A: exit 3, stderr single JSON
schemas/cli-error-envelope.schema.json, no stdout bytes. - Phase B: after successful registry atomic write and read-back (see Registry file and canonical JSON below), optionally atomic-write
--emit-events(may be zero bytes when there are no exported row tools), then emit one stdout line:stableStringify(certificate) + "\n", schema-valid againstschemas/outcome-certificate-v3.schema.json(runKind: "quick_preview"for quick). The same certificate carriesevidenceCompleteness; exit 0/1/2. agentskeptic enforce: stateful CI lifecycle command (baseline/check/accept) with exit 4 on drift. Full stdout/stderr rules and the authoritative exit table live only in agentskeptic.md — Enforce stream contract (normative) (do not duplicate that table here). Hosted posture names (lifecycle_state, accept/rerun sequencing) come only fromdocs/outcome-certificate-normative.md(Hosted enforcement lifecycle), not quick-verify algorithms.
A.3 Registry file and canonical JSON
Shape. --export-registry file contains only a UTF-8 JSON array (possibly []) of objects each satisfying Advanced schemas/tools-registry.schema.json items shape for a single entry (the file is not wrapped with { "tools": [...] }).
Canonical bytes. Define canonicalToolsArrayUtf8(tools: object[]): string:
- Serialize the array with
stableStringify: recursively, every JSON object has keys sorted by UTF-16 code unit lexicographic order (same comparator ascompareUtf16Idinsrc/resolveExpectation.ts); arrays keep implementation order (see Tool order). No ASCII space after:or,. No trailing newline after the closing]of the top-level array. - Output string is Unicode code points; encode as UTF-8 without BOM for disk.
Identity with stdout. Let F = filesystem contents of --export-registry after successful run. Let T = exported tools from the same run (the array used to write --export-registry, represented in the emitted certificate's quick-derived evidence chain). Required: F === canonicalToolsArrayUtf8(T) as strings (UTF-8 decode of F equals the canonical string).
Tool order in array. Sort exported tools by toolId ascending UTF-16 order.
Atomic write (phase B ordering).
- Build complete
QuickVerifyReportin memory (including finalexportableRegistry.tools). - Compute
registryUtf8 = canonicalToolsArrayUtf8(report.exportableRegistry.tools). atomicWriteUtf8File(exportRegistryPath, registryUtf8):mkdirSync(dirname(exportRegistryPath), { recursive: true }); write toexportRegistryPath + ".tmp." + randomSuffixin same directory;fsyncSync;renameSyncto final path;readFileSync(exportRegistryPath, "utf8")must strict-equalregistryUtf8; else phase A.- If
--emit-events <path>is present:atomicWriteUtf8File(emitEventsPath, eventsUtf8)whereeventsUtf8is UTF-8 NDJSON (schemaVersion: 1tool_observedlines for each exported tool inexportableRegistry.tools,seqin sorted-toolIdorder) or the empty string (final file length 0) when there are no exported tools. - Build an Outcome Certificate from quick results and serialize
certificateUtf8 = stableStringify(certificate) + "\n". process.stdout.write(certificateUtf8).
Never emit any stdout byte before step 3 completes successfully.
A.3a Human stderr (anchors)
Not an integration contract except for three lines, in order, as whole lines:
=== quick-verify human report ===Rollup (inferred, provisional): passor: failor: uncertain(matches rollup; wording fromverdictLineinsrc/quickVerify/quickVerifyHumanCopy.ts)=== end quick-verify human report ===
Lines 4–6 after the anchors are fixed banner strings exported as QUICK_VERIFY_BANNER_LINE_1, QUICK_VERIFY_BANNER_LINE_2, and QUICK_VERIFY_BANNER_LINE_3 from src/quickVerify/formatQuickVerifyHumanReport.ts.
Under each unit bullet (after that unit’s summary line), the formatter emits exactly four lines in dimension-ID order, using the same prefixes as src/reconciliationPresentation.ts: declared:, expected:, observed_database:, verification_verdict: — values are copied only from report.units[i].reconciliation on stdout JSON (see reconciliation-vocabulary.md).
A.3b Synthetic tool_observed lines (contract replay)
After sorting exported tools by toolId (UTF-16), emit one NDJSON object per tool with schemaVersion: 1, workflowId equal to the CLI --workflow-id value for that run, integer seq from 0 upward in that sorted order, type: "tool_observed", and toolId equal to the registry entry’s toolId.
- Exported
sql_rowtools:paramsis{ "__qvFields": { … } }with keys sorted UTF-16 and values from the rowVerificationRequest.requiredFields(same as pre-relational-export behavior). - Exported eligible
related_existstools (Advancedsql_relationalwith a single const-onlyrelated_existscheck): synthetic lines use"params": {}(empty JSON object). Batch resolution uses only registryconstfields; seeresolveVerificationRequestforsql_relational+related_existsinsrc/resolveExpectation.ts.
Eligibility (normative name): eligible_export_related_exists — a related_exists quick unit is exportable when its stdout fields satisfy the predicate implemented in src/quickVerify/runQuickVerify.ts (verified rollup unit, confidence gate, and resolveVerificationRequest(prospectiveEntry, {}) succeeds).
Additional prose after those lines may change without bumping quickVerifyVersion. Integrators must use stdout JSON and exit codes for automation.
Appendix H — Human copy identifiers (normative names only)
English text for ingest lines and unit reason hints is defined in src/quickVerify/quickVerifyHumanCopy.ts (ingest messages and imports from src/verificationUserPhrases.ts). Banner lines and per-unit reconciliation stderr lines: src/quickVerify/formatQuickVerifyHumanReport.ts (stderr values must mirror units[].reconciliation only). Machine non-guarantee and declared / expected / observed copy in quick-processing report construction: src/quickVerify/quickVerifyProductTruth.ts (field productTruth on QuickVerifyReport, schemaVersion 4). Identifiers include at least: MSG_NO_TOOL_CALLS, MSG_NO_STRUCTURED_TOOL_ACTIVITY, HUMAN_REPORT_BEGIN, HUMAN_REPORT_END, QUICK_VERIFY_BANNER_LINE_1, QUICK_VERIFY_BANNER_LINE_2, QUICK_VERIFY_BANNER_LINE_3, verdictLine, humanLineForIngestReasonCode, humanFragmentForReasonCode. Do not duplicate the strings in this doc outside a fenced block that cites one of those file paths.
Documentation authority (which markdown owns product vs algorithms): see verification-product.md.
A.4 Verdict rollup
failif any unitfail.- Else
passif at least one unit and allverified. - Else
uncertain.
A.5 Ingest ladder (ordered)
Constants: MAX_INPUT_BYTES = 8_388_608, MAX_ACTIONS = 50.
L0: Reject input byte length > MAX_INPUT_BYTES → phase B, ingest.reasonCodes = [INGEST_INPUT_TOO_LARGE].
L1: Strip UTF-8 BOM if present.
L1b: Remove all CSI ANSI sequences from the whole buffer using ECMAScript regex /\u001b\[[\d;?]*[\s-/]*[@-~]/g (no OSC / other families in this spec version).
Early empty: If buffer.trim().length === 0 after L1–L1b → return zero actions, ingest.reasonCodes = [INGEST_NO_ACTIONS], malformedLineCount = 0.
L2: Try JSON.parse entire buffer as value root; run extractActions(root); if ≥1 action, ladder stops (do not run L3–L4). If parse succeeds but 0 actions, continue to L3. If parse throws, continue to L3.
L3: Split buffer by \n. For each line with non-empty trim(line):
- Let
s1=line.trim()with at most one leading match removed for P1/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s+/, then trim. - Try
JSON.parse(s1); on success → extractActions and continue to next line. - Else if
s1matches P2/^(?:DEBUG|INFO|WARN|WARNING|ERROR|TRACE)\s+/i, lets2=s1with one P2 prefix removed, trim; tryJSON.parse(s2); on success → extractActions. - Else if
s1matches P3/^\[[^\]]{1,64}\]\s+/, lets3=s1with one P3 prefix removed, trim; tryJSON.parse(s3); on success → extractActions. - Else: increment
malformedLineCount; appendMALFORMED_LINEto the internal L3 list (see mixed-stream rule below).
If ≥1 action total from L3, stop L3–L4 and return with mixed-stream rule applied to ingest.reasonCodes.
L4: Scan for balanced {…} substrings (greedy outermost scan, no nesting cross-capture); each substring JSON.parse → extractActions. If ≥1 action, stop (apply mixed-stream rule to ingest.reasonCodes).
L5: If zero actions after L2–L4: phase B, verdict=uncertain, append MALFORMED_LINE once per failed L3 line in encounter order, then exactly one terminal ingest code:
INGEST_NO_ACTIONSonly when the buffer was whitespace-only (handled at Early empty; this terminal is not combined withMALFORMED_LINE).INGEST_NO_STRUCTURED_TOOL_ACTIVITYwhen the buffer was non-empty after trim but zero actions were extracted.
Mixed stream: If the final action count is ≥1, ingest.reasonCodes must contain no MALFORMED_LINE entries; malformedLineCount still reflects the count of L3 lines that failed parse after salvage.
A.6 extractActions(value)
- If
valueis object withtool_callsarray: for each elementcoftool_calls(maxMAX_ACTIONStotal across entire run), recursivelyextractActions(c). - If
valueis object: if it has tool name key (first hit in ordertoolId,tool,name,function.name,action), build one action:toolName= string at that key;params= param bag: whenfunctionis a non-array object with non-empty stringfunction.nameand that object has own propertyarguments, use nestedfunction.argumentsfirst (non-array object as-is; else if string trim starts with{or[,JSON.parsein try/catch and if the result is a plain non-null object use it); otherwise take the first of top-levelparams|arguments|inputin order the same way; if neither applies, shallow copy of own keys excluding tool-name keys andtool_calls; emit one action. - If
valueis array: runextractActionson each element untilMAX_ACTIONSreached; if exceeded, phase Buncertain, appendINGEST_ACTION_CAPonce toingest.reasonCodesand stop adding.
A.7 Flatten (per action)
DFS; maxDepth=6, maxNodes=500 per action. Dot-path keys; arrays: only index into object elements [i]; primitives in arrays → skip primitive arrays for expansion (no paths). Cycles: replace with null. Output: flat map path → scalar (string|number|boolean|null).
A.8 Dedupe
actionKey = stableStringify({ toolName, flat }) with sorted object keys UTF-16. Keep first action per key; later duplicates: push warning { code: DEDUPE_DROPPED, actionKey } only (no second unit).
A.9 Decomposition (row buckets + fallback + relational)
A.9.1 Table variants. For each catalog table name T (exact identifier from catalog):
V0 = TV1 =lowercase ASCII fold ofV0(onlyA-Z→a-z)V2 = englishSingular(V1)defined: if endsiesand length≥4 → replace trailingieswithy; else if endssesorxesorchesorshes→ strip lastes; else if endssand notss→ strip lasts; else unchangedV3 = englishPlural(V1)defined: if endsyand prev char not vowel → stripy+ies; else if endss→ unchanged; else appends
variants(T) = unique([V0,V1,V2,V3]) string compare UTF-16.
A.9.2 Tool tokens. tokens(toolName) = split on /[^a-zA-Z0-9]+/, filter empty, map lowercase.
A.9.3 Table score for action. tableScore(A,T) = max over t in tokens(A.toolName), v in variants(T) of: exact t===v → 1.0; t includes v or v includes t → 0.75; Levenshtein ratio ≥0.85 → 0.7; else 0.
A.9.4 Path primary segment. For flat path p, seg0 = substring before first . if any, else p (full path if no dot).
A.9.5 Segment–table score. Let s = ASCII-lowercase of seg0 (only A-Z → a-z). For catalog table T, score_p(T) = max over v in variants(T) of tokenMatchScore(s, v) where tokenMatchScore(s, v) is: s === v → 1.0; else if s.includes(v) || v.includes(s) → 0.75; else Levenshtein ratio of s and v ≥ 0.85 → 0.7; else 0.
A.9.6 Assign path to bucket. Let W = argmax_T score_p(T) over all user tables. If score_p(W) < T_TABLE (0.60), assign p to fallback bucket __global__. Tie-break when two or more tables share the same top score: choose the table whose name is smallest UTF-16; if still tied, choose smallest UTF-16 among variants(T) matched at that score (lexicographic on variant string).
A.9.7 Row units. One row unit per non-empty bucket of paths (including __global__). Column-side keys: for path p in bucket for table W: if p contains . and p is not in __global__, mapping key = substring after first .; otherwise mapping key = full p.
For __global__ bucket only: target table for the row unit = argmax_T tableScore(A,T) with same tie-break as A.9.6 on table names; if best score < T_TABLE, unit is uncertain with MAPPING_LOW_CONFIDENCE.
A.9.8 Relational units. After row units for an action, for each FK edge (childTable, childCols → parentTable, parentCols) from catalog: if flat map contains mappable scalars for all child cols and parent cols with column score ≥ T_COL (0.50), emit one related_exists unit (SQL shape per relational-verification.md related_exists). Cap 20 total units per run; if exceeded, UNIT_CAP_EXCEEDED once on report header and omit further units.
A.10 Mapping and export merge
Column scoring: precedence max of exact 1.0, case-fold 0.95, snake/camel normalize 0.90, strip Id/_id 0.85, Levenshtein≥0.80 → ratio.
Ambiguity: top two (table,binding) overall scores S1,S2; if S1 < T_OVERALL (0.55) → MAPPING_LOW_CONFIDENCE; if S1-S2 ≤ T_AMBIGUITY_DELTA (0.08) → MAPPING_AMBIGUOUS with exactly two alternates listed UTF-16 order.
Identity: PK columns first; else unique constraint with fewest columns; tie UTF-16 table name; else MAPPING_NO_UNIQUE_KEY.
Export: exportableRegistry.tools entries sorted by toolId UTF-16. Each exported entry’s toolId MUST be unique: default quick:${unitId} where unitId is report units[].unitId in UTF-16 order; if a collision still occurs, suffix :${n} incrementing n from 1. effectDescriptionTemplate is per unit; one registry entry per exported unit.
A.11 Constants
T_TABLE=0.60, T_COL=0.50, T_OVERALL=0.55, T_AMBIGUITY_DELTA=0.08, T_EXPORT=0.55.
A.12 Row and relational verification
Row: SELECT LIMIT 2; 0 rows ROW_ABSENT; ≥2 DUPLICATE_ROWS; else scalar compare verificationScalarsEqual from src/valueVerification.ts. Relational: RELATED_ROWS_ABSENT on false EXISTS.
A.13 Scope string (fixed)
Report scope.quickVerifyVersion = 1.2.1; scope.capabilities = fixed enum array ["inferred_row","inferred_related_exists"]; scope.ingestContract = structured_tool_activity; scope.groundTruth = read_only_sql; scope.limitations = fixed tuple
["quick_verify_inferred_row_and_related_exists_only","no_multi_effect_contract","no_destructive_or_forbidden_row_contract","contract_replay_export_row_and_eligible_related_exists_tools"] (see schema).
Report schemaVersion is 4 (includes required productTruth, required per-unit reconciliation with four string fields, and declared / expected / observed layer copy; non-pass units require correctnessDefinition per schema). Report verificationMode is always inferred. Per-unit sourceAction and contractEligible and merged row verification fields are defined only in schemas/quick-verify-report.schema.json—do not restate here.
A.14 Param-pointer row export (1.2.1)
Predicate eligible_export_sql_row_param_pointer (name frozen). For a row unit, Quick MAY export to exportableRegistry when all hold: verdict === "verified"; T_COL ≤ confidence < T_EXPORT (T_COL=0.50, T_EXPORT=0.55); every PK mapping flat key is pointer-complete (flatKeyToJsonPointer succeeds); and resolveVerificationRequest on the pointer-style registry entry with buildSyntheticRowParams(action.params, __qvFields) succeeds with normalizedSqlRowRequestFingerprint(resolved) === normalizedSqlRowRequestFingerprint(plan.request). Otherwise the legacy rule applies: export only when confidence ≥ T_EXPORT with const-only registry identity.
buildSyntheticRowParams(actionParams, qvFields) merge: M1 deep-clone actionParams via JSON.parse(JSON.stringify(actionParams)) (throw → pointer path off). M2 set top-level __qvFields from qvFields. M3–M4 no other mutations. M5 synthetic NDJSON params uses stableStringify on the merged object for golden bytes.