🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@usekaval/kaval

Package Overview
Dependencies
Maintainers
1
Versions
15
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@usekaval/kaval - npm Package Compare versions

Comparing version
0.7.0
to
0.7.1
+124
dist/verify/decision.d.ts
/**
* `check-decision/1.1.0` — THE PUBLISHED DECISION TABLE, EXECUTABLE.
*
* A signed receipt exists to be an exhibit. Until this module shipped, a holder could check the
* SIGNATURE offline but had to run Kaval's server code to re-derive the VERDICT — so a skeptic could
* fairly answer "to check your verdict I must run your software", which is exactly the objection an
* appeal packet exists to remove. The table below was already published as documentation; this file
* is that same table as code, and it is the code the issuer runs.
*
* THIS IS THE SINGLE SOURCE OF TRUTH, NOT A COPY OF ONE.
* `apps/server/src/check/decide.ts` re-exports these bindings; it does not restate them. A
* reimplementation that drifted would accept verdicts the server never reaches, which is worse than
* publishing nothing at all, so `apps/server/test/check-decision-shared-source.test.ts` fails if the
* server's `decideCheck` is ever anything but the function object exported here — and fails again if
* the receipt-issuing pipeline ever routes around it.
*
* COMPATIBILITY SURFACE. Because verifiers a customer runs now execute this table, changing a row
* changes what those verifiers accept: a receipt issued under a new table would be REFUSED by every
* deployed verifier that still knows the old one. That is a BREAKING CHANGE, deliberately made
* expensive — for an audit-grade artifact the decision table *should* be hard to change quietly. The
* procedure is: bump `CHECK_DECISION_RULE_VERSION`, publish the new table, and keep accepting the
* old one for receipts that name it. Nothing here may change under the existing version string.
*
* ZERO DEPENDENCIES, ON PURPOSE. The enums below are restated rather than imported from
* `@kaval/contracts`: this package is mirrored into the public Apache-2.0 thin client, where nothing
* from the private repo exists. `apps/server/src/check/contracts.ts` asserts at COMPILE TIME that
* its own `Materiality` / `FactStatus` / `FreshnessFailure` are the same sets in both directions, so
* adding an internal value without publishing it here fails the build rather than a receipt.
*
* | # | condition | verdict |
* |---|----------------------------------------------------------------------------------|---------|
* | 1 | any high/critical fact is `changed` | BLOCK |
* | 2 | any uncontested critical fact is `unknown` | BLOCK |
* | 3 | any contested fact is `unknown` | REVIEW |
* | 4 | any other changed/unknown/timeout/stale-pending fact, or compile uncertainty | REVIEW |
* | 5 | every material fact `holds` on a fresh basis | ALLOW |
*
* Top-down, FIRST MATCH WINS. Row 4 is vacuously true for an empty fact list; a check that
* identified no facts at all must therefore set `compilationUncertain` (row 3) rather than rely on
* the table to fail closed for it.
*/
/** Published with every new receipt so its verdict stays re-derivable from the fact list. */
export declare const CHECK_DECISION_RULE_VERSION = "check-decision/1.1.0";
export declare const CHECK_DECISION_RULE_VERSIONS: readonly ["check-decision/1.0.0", "check-decision/1.1.0"];
export type CheckDecisionRuleVersion = (typeof CHECK_DECISION_RULE_VERSIONS)[number];
/** The entire taxonomy. Eight codes, no synonyms, no free text. */
export declare const CHECK_REASON_CODES: readonly ["ALL_FACTS_HOLD", "FACT_CHANGED", "FACT_EXPIRED", "FACT_UNKNOWN", "SOURCE_UPDATED_PENDING_REVIEW", "SOURCE_UNREACHABLE", "NEW_FACT_UNVERIFIED", "COMPILATION_UNCERTAIN"];
export type CheckReasonCode = (typeof CHECK_REASON_CODES)[number];
export declare const CHECK_VERDICTS: readonly ["ALLOW", "REVIEW", "BLOCK"];
export type CheckVerdict = (typeof CHECK_VERDICTS)[number];
/** How a fact's status was obtained. Mirrors the receipt's per-fact `method`. */
export declare const CHECK_FACT_METHODS: readonly ["state", "live", "timeout"];
export type CheckFactMethod = (typeof CHECK_FACT_METHODS)[number];
/** The public, three-valued projection of an internal claim assessment. */
export declare const CHECK_FACT_STATES: readonly ["holds", "changed", "unknown"];
export type CheckFactState = (typeof CHECK_FACT_STATES)[number];
export declare const CHECK_MATERIALITIES: readonly ["low", "medium", "high", "critical"];
export type CheckMateriality = (typeof CHECK_MATERIALITIES)[number];
/** Why a stored fact state could not be served, published as part of the receipt. */
export declare const CHECK_FRESHNESS_FAILURES: readonly ["stale", "dormant", "basis_superseded", "source_unreachable", "ttl_expired"];
export type CheckFreshnessFailure = (typeof CHECK_FRESHNESS_FAILURES)[number];
export interface CheckDecisionFact {
materiality: CheckMateriality;
status: CheckFactState;
/** How the status was obtained. `timeout` means the live path ran out of `max_wait_ms`. */
method: CheckFactMethod;
/** Internal temporal state when known — the only thing that distinguishes EXPIRED from CHANGED. */
temporalState?: string | null | undefined;
/** Why a stored row could not be served. Drives SOURCE_UNREACHABLE. */
freshnessFailure?: CheckFreshnessFailure | undefined;
/** A known fact whose basis moved and whose re-evaluation has not finished yet. */
stalePending?: boolean | undefined;
/** True when no stored state existed and the live path did not establish one. */
novel?: boolean | undefined;
/** True when at least one recorded source contests the conclusion's backing. */
contested?: boolean | undefined;
}
export interface CheckDecisionOptions {
/** Claim extraction failed or produced nothing usable for the submitted action. */
compilationUncertain?: boolean | undefined;
/** Omit for the latest table. Receipt projection sets the version named by the artifact. */
ruleVersion?: CheckDecisionRuleVersion | undefined;
}
export interface CheckDecision {
verdict: CheckVerdict;
reason_codes: CheckReasonCode[];
decision_rule_version: string;
}
export declare function decideCheck(facts: readonly CheckDecisionFact[], options?: CheckDecisionOptions): CheckDecision;
/** A receipt that predates the discriminators cannot be re-derived; it is refused, never guessed. */
export declare class ReceiptNotSelfDerivableError extends Error {
readonly reason: string;
constructor(reason: string);
}
/**
* Project a signed receipt back onto `decideCheck`'s inputs — the whole product claim, executable.
*
* Feed the result to `decideCheck` and it must reproduce the receipt's own `decision` and
* `reason_codes` exactly. Nothing else may be consulted: not a database, not the response body, and
* emphatically not `receipt.reason_codes` itself (reading the answer off the answer would make the
* round trip vacuous). Anyone holding the JSON can do this with the published decision table.
*
* The parameter is `unknown` because a verifier's input is a document someone handed it, not a value
* a type system already vouched for. Every field the table reads is checked here, and the ONLY
* permitted outcomes are a complete decision input or a throw.
*
* FAILS CLOSED on an under-specified receipt. A document missing the discriminators would re-derive
* a *more permissive* verdict than was issued — a stale-pending REVIEW would read as ALLOW — so it
* is refused outright. Silently producing the wrong verdict is the one outcome an auditor cannot
* detect, which makes it the one outcome this function must never have. The same reasoning covers a
* value outside a published enum: an unrecognised `materiality` is not "not blocking", it is a
* document this table cannot answer for.
*/
export declare function checkDecisionInputFromReceipt(receipt: unknown): {
facts: CheckDecisionFact[];
options: CheckDecisionOptions;
};
/**
* The one-call form: hand it a receipt, get back the verdict its own facts imply.
*
* This never reads `receipt.decision` or `receipt.reason_codes`. Comparing the answer to those two
* fields is the caller's job — and is what `verifyReceipt(..., { derive_verdict: true })` does.
*/
export declare function deriveCheckDecision(receipt: unknown): CheckDecision;
/**
* `check-decision/1.1.0` — THE PUBLISHED DECISION TABLE, EXECUTABLE.
*
* A signed receipt exists to be an exhibit. Until this module shipped, a holder could check the
* SIGNATURE offline but had to run Kaval's server code to re-derive the VERDICT — so a skeptic could
* fairly answer "to check your verdict I must run your software", which is exactly the objection an
* appeal packet exists to remove. The table below was already published as documentation; this file
* is that same table as code, and it is the code the issuer runs.
*
* THIS IS THE SINGLE SOURCE OF TRUTH, NOT A COPY OF ONE.
* `apps/server/src/check/decide.ts` re-exports these bindings; it does not restate them. A
* reimplementation that drifted would accept verdicts the server never reaches, which is worse than
* publishing nothing at all, so `apps/server/test/check-decision-shared-source.test.ts` fails if the
* server's `decideCheck` is ever anything but the function object exported here — and fails again if
* the receipt-issuing pipeline ever routes around it.
*
* COMPATIBILITY SURFACE. Because verifiers a customer runs now execute this table, changing a row
* changes what those verifiers accept: a receipt issued under a new table would be REFUSED by every
* deployed verifier that still knows the old one. That is a BREAKING CHANGE, deliberately made
* expensive — for an audit-grade artifact the decision table *should* be hard to change quietly. The
* procedure is: bump `CHECK_DECISION_RULE_VERSION`, publish the new table, and keep accepting the
* old one for receipts that name it. Nothing here may change under the existing version string.
*
* ZERO DEPENDENCIES, ON PURPOSE. The enums below are restated rather than imported from
* `@kaval/contracts`: this package is mirrored into the public Apache-2.0 thin client, where nothing
* from the private repo exists. `apps/server/src/check/contracts.ts` asserts at COMPILE TIME that
* its own `Materiality` / `FactStatus` / `FreshnessFailure` are the same sets in both directions, so
* adding an internal value without publishing it here fails the build rather than a receipt.
*
* | # | condition | verdict |
* |---|----------------------------------------------------------------------------------|---------|
* | 1 | any high/critical fact is `changed` | BLOCK |
* | 2 | any uncontested critical fact is `unknown` | BLOCK |
* | 3 | any contested fact is `unknown` | REVIEW |
* | 4 | any other changed/unknown/timeout/stale-pending fact, or compile uncertainty | REVIEW |
* | 5 | every material fact `holds` on a fresh basis | ALLOW |
*
* Top-down, FIRST MATCH WINS. Row 4 is vacuously true for an empty fact list; a check that
* identified no facts at all must therefore set `compilationUncertain` (row 3) rather than rely on
* the table to fail closed for it.
*/
/** Published with every new receipt so its verdict stays re-derivable from the fact list. */
export const CHECK_DECISION_RULE_VERSION = "check-decision/1.1.0";
export const CHECK_DECISION_RULE_VERSIONS = [
"check-decision/1.0.0",
CHECK_DECISION_RULE_VERSION,
];
/** The entire taxonomy. Eight codes, no synonyms, no free text. */
export const CHECK_REASON_CODES = [
"ALL_FACTS_HOLD",
"FACT_CHANGED",
"FACT_EXPIRED",
"FACT_UNKNOWN",
"SOURCE_UPDATED_PENDING_REVIEW",
"SOURCE_UNREACHABLE",
"NEW_FACT_UNVERIFIED",
"COMPILATION_UNCERTAIN",
];
export const CHECK_VERDICTS = ["ALLOW", "REVIEW", "BLOCK"];
/** How a fact's status was obtained. Mirrors the receipt's per-fact `method`. */
export const CHECK_FACT_METHODS = ["state", "live", "timeout"];
/** The public, three-valued projection of an internal claim assessment. */
export const CHECK_FACT_STATES = ["holds", "changed", "unknown"];
export const CHECK_MATERIALITIES = [
"low",
"medium",
"high",
"critical",
];
/** Why a stored fact state could not be served, published as part of the receipt. */
export const CHECK_FRESHNESS_FAILURES = [
"stale",
"dormant",
"basis_superseded",
"source_unreachable",
"ttl_expired",
];
function isBlockingMateriality(materiality) {
return materiality === "high" || materiality === "critical";
}
/**
* Reason codes are evidence, not commentary: every code emitted must be traceable to a fact row (or
* to compilation) in the same receipt. Order is stable — table order, then fact order — so two
* identical decision inputs produce the same reason-code order. Receipt IDs and times remain unique.
*/
function reasonCodes(facts, verdict, compilationUncertain) {
if (verdict === "ALLOW")
return ["ALL_FACTS_HOLD"];
const codes = new Set();
if (compilationUncertain)
codes.add("COMPILATION_UNCERTAIN");
for (const fact of facts) {
if (fact.status === "changed") {
codes.add(fact.temporalState === "expired" ? "FACT_EXPIRED" : "FACT_CHANGED");
continue;
}
if (fact.stalePending === true) {
codes.add("SOURCE_UPDATED_PENDING_REVIEW");
continue;
}
if (fact.status === "unknown") {
if (fact.freshnessFailure === "source_unreachable")
codes.add("SOURCE_UNREACHABLE");
else if (fact.novel === true || fact.method === "timeout")
codes.add("NEW_FACT_UNVERIFIED");
else
codes.add("FACT_UNKNOWN");
}
}
// A non-ALLOW verdict always names at least one cause. The only way to reach here with nothing
// recorded is a `holds` fact that timed out mid-revalidation, which is exactly UNKNOWN-shaped.
if (codes.size === 0)
codes.add("FACT_UNKNOWN");
return [...codes];
}
function decideCheckForVersion(facts, options, version) {
const compilationUncertain = options.compilationUncertain === true;
const verdict = (() => {
// Row 1 — a material fact that CHANGED is the whole product; it blocks before anything else.
if (facts.some((fact) => fact.status === "changed" && isBlockingMateriality(fact.materiality))) {
return "BLOCK";
}
// Version 1.0.0 blocked every critical unknown. Version 1.1.0 stops a named contest at REVIEW.
if (facts.some((fact) => fact.status === "unknown" &&
fact.materiality === "critical" &&
(version === "check-decision/1.0.0" || fact.contested !== true))) {
return "BLOCK";
}
// Rows 3 and 4 — a named evidence contest stops at REVIEW, regardless of materiality.
if (compilationUncertain ||
facts.some((fact) => fact.status !== "holds" ||
fact.method === "timeout" ||
fact.stalePending === true)) {
return "REVIEW";
}
// Row 4.
return "ALLOW";
})();
return {
verdict,
reason_codes: reasonCodes(facts, verdict, compilationUncertain),
decision_rule_version: version,
};
}
export function decideCheck(facts, options = {}) {
return decideCheckForVersion(facts, options, options.ruleVersion ?? CHECK_DECISION_RULE_VERSION);
}
/* ------------------------------------------------------------------ *
* Offline re-derivation *
* ------------------------------------------------------------------ */
/** A receipt that predates the discriminators cannot be re-derived; it is refused, never guessed. */
export class ReceiptNotSelfDerivableError extends Error {
reason;
constructor(reason) {
super(`this receipt cannot be re-derived offline: ${reason}`);
this.reason = reason;
this.name = "ReceiptNotSelfDerivableError";
}
}
function objectOrNull(value) {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value
: null;
}
function memberOf(values, value) {
return (typeof value === "string" && values.includes(value));
}
/**
* Project a signed receipt back onto `decideCheck`'s inputs — the whole product claim, executable.
*
* Feed the result to `decideCheck` and it must reproduce the receipt's own `decision` and
* `reason_codes` exactly. Nothing else may be consulted: not a database, not the response body, and
* emphatically not `receipt.reason_codes` itself (reading the answer off the answer would make the
* round trip vacuous). Anyone holding the JSON can do this with the published decision table.
*
* The parameter is `unknown` because a verifier's input is a document someone handed it, not a value
* a type system already vouched for. Every field the table reads is checked here, and the ONLY
* permitted outcomes are a complete decision input or a throw.
*
* FAILS CLOSED on an under-specified receipt. A document missing the discriminators would re-derive
* a *more permissive* verdict than was issued — a stale-pending REVIEW would read as ALLOW — so it
* is refused outright. Silently producing the wrong verdict is the one outcome an auditor cannot
* detect, which makes it the one outcome this function must never have. The same reasoning covers a
* value outside a published enum: an unrecognised `materiality` is not "not blocking", it is a
* document this table cannot answer for.
*/
export function checkDecisionInputFromReceipt(receipt) {
const document = objectOrNull(receipt);
if (document === null) {
throw new ReceiptNotSelfDerivableError("it is not a JSON object");
}
if (typeof document["compilation_uncertain"] !== "boolean") {
throw new ReceiptNotSelfDerivableError("it does not state whether compilation was uncertain");
}
const factList = document["facts"];
if (!Array.isArray(factList)) {
throw new ReceiptNotSelfDerivableError("it does not carry a fact list");
}
const ruleVersion = document["decision_rule_version"];
const facts = factList.map((entry, index) => {
const fact = objectOrNull(entry);
if (fact === null)
throw new ReceiptNotSelfDerivableError(`fact ${index} is not a JSON object`);
if (typeof fact["stale_pending"] !== "boolean" ||
typeof fact["novel"] !== "boolean") {
throw new ReceiptNotSelfDerivableError(`fact ${index} omits the stale-pending/novel discriminators`);
}
// `temporal_state` is what separates FACT_EXPIRED from FACT_CHANGED, so an ABSENT one would
// silently downgrade the code rather than fail. `null` is a legitimate value (the state was
// never known); `undefined` is a document that cannot answer the question.
if (fact["temporal_state"] === undefined) {
throw new ReceiptNotSelfDerivableError(`fact ${index} omits its temporal state`);
}
// An unrecognised failure reason must not degrade to "no failure": that is exactly how a
// SOURCE_UNREACHABLE would quietly re-derive as the milder FACT_UNKNOWN.
const freshnessFailure = fact["freshness_failure"];
if (freshnessFailure !== null &&
!memberOf(CHECK_FRESHNESS_FAILURES, freshnessFailure)) {
throw new ReceiptNotSelfDerivableError(`fact ${index} carries a freshness failure this contract does not define`);
}
// The two inputs rows 1-4 branch on. A `materiality` or `state` this table does not define
// would fall through every row into the permissive direction, so it is refused instead.
const materiality = fact["materiality"];
if (!memberOf(CHECK_MATERIALITIES, materiality)) {
throw new ReceiptNotSelfDerivableError(`fact ${index} carries a materiality this contract does not define`);
}
const state = fact["state"];
if (!memberOf(CHECK_FACT_STATES, state)) {
throw new ReceiptNotSelfDerivableError(`fact ${index} carries a state this contract does not define`);
}
const method = fact["method"];
if (!memberOf(CHECK_FACT_METHODS, method)) {
throw new ReceiptNotSelfDerivableError(`fact ${index} carries a method this contract does not define`);
}
const temporalState = fact["temporal_state"];
if (temporalState !== null && typeof temporalState !== "string") {
throw new ReceiptNotSelfDerivableError(`fact ${index} carries a non-textual temporal state`);
}
// Older receipts did not publish semantic roles. They remain derivable as non-contested.
const basis = Array.isArray(fact["basis"]) ? fact["basis"] : [];
const contested = basis.some((entry) => objectOrNull(entry)?.["role"] === "contesting");
return {
materiality,
status: state,
method,
temporalState,
stalePending: fact["stale_pending"],
novel: fact["novel"],
...(contested ? { contested: true } : {}),
...(freshnessFailure === null ? {} : { freshnessFailure }),
};
});
if (!memberOf(CHECK_DECISION_RULE_VERSIONS, ruleVersion)) {
throw new ReceiptNotSelfDerivableError(typeof ruleVersion === "string"
? `it names unsupported decision rule ${ruleVersion}`
: "it names no decision rule version");
}
return {
facts,
options: {
compilationUncertain: document["compilation_uncertain"],
ruleVersion,
},
};
}
/**
* The one-call form: hand it a receipt, get back the verdict its own facts imply.
*
* This never reads `receipt.decision` or `receipt.reason_codes`. Comparing the answer to those two
* fields is the caller's job — and is what `verifyReceipt(..., { derive_verdict: true })` does.
*/
export function deriveCheckDecision(receipt) {
const { facts, options } = checkDecisionInputFromReceipt(receipt);
return decideCheck(facts, options);
}
+8
-1

@@ -18,2 +18,4 @@ #!/usr/bin/env node

--require-fresh Exit non-zero unless freshness is "fresh"
--derive-verdict Also re-derive a check receipt's ALLOW/REVIEW/BLOCK from its own
facts using the published decision table, and require it to match
--allow-http-loopback Permit http://localhost/127.0.0.0/8/::1 for local development

@@ -24,3 +26,4 @@ --compact Emit compact JSON

Exit status 0 means the signature is valid and the key is trusted. Freshness is reported
separately unless --require-fresh is supplied.
separately unless --require-fresh is supplied. With --derive-verdict the verdict must also
re-derive from the receipt's own facts, and the "decision" block explains any mismatch.
`;

@@ -42,2 +45,3 @@ function argumentError(message) {

requireFresh: false,
deriveVerdict: false,
allowHttpLoopback: false,

@@ -50,2 +54,4 @@ compact: false,

result.requireFresh = true;
else if (flag === "--derive-verdict")
result.deriveVerdict = true;
else if (flag === "--allow-http-loopback")

@@ -131,2 +137,3 @@ result.allowHttpLoopback = true;

...(args.at ? { at: args.at } : {}),
...(args.deriveVerdict ? { derive_verdict: true } : {}),
});

@@ -133,0 +140,0 @@ print(result, args.compact);

+13
-4

@@ -6,6 +6,14 @@ /**

* no `node:https`, no sockets. It reads a receipt and a key document you already hold and answers
* three separate questions — is the Ed25519 signature over the exact canonical bytes, is the key
* trusted, and is the receipt fresh at the instant you name. That is the whole point of the
* subpath, and `test/verify/no-network.test.ts` holds the import graph to it.
* four separate questions — is the Ed25519 signature over the exact canonical bytes, is the key
* trusted, is the receipt fresh at the instant you name, and (only when you pass
* `derive_verdict: true`) does the receipt's stated ALLOW/REVIEW/BLOCK actually follow from the
* facts the receipt itself carries. That is the whole point of the subpath, and
* `test/verify/no-network.test.ts` holds the import graph to it.
*
* The fourth question is what makes a receipt an appeal packet rather than a signed assertion. The
* decision table it runs is `./decision.js` — `check-decision/1.1.0`, the same table Kaval's issuer
* executes, carried here so a holder never has to run Kaval's software to check Kaval's verdict.
* `decision.ts` is a mirrored copy of the issuer's; `test/verify/mirror-pin.test.ts` pins the shared
* conformance vectors that make the two answer identically.
*
* `verifyWebhookSignature` is here for the same reason: authenticating an inbound `fact_state.delta`

@@ -21,6 +29,7 @@ * is a pure HMAC over bytes you were handed, and a receiver that had to reach the network to decide

export { canonicalUnsignedReceiptBytes, canonicalUnsignedReceiptJson, MAX_JSON_NUMBER_CHARACTERS, parseJsonStrict, stableCanonicalJson, } from "./canonicalize.js";
export { CHECK_DECISION_RULE_VERSION, CHECK_DECISION_RULE_VERSIONS, CHECK_FACT_METHODS, CHECK_FACT_STATES, CHECK_FRESHNESS_FAILURES, CHECK_MATERIALITIES, CHECK_REASON_CODES, CHECK_VERDICTS, ReceiptNotSelfDerivableError, checkDecisionInputFromReceipt, decideCheck, deriveCheckDecision, type CheckDecision, type CheckDecisionFact, type CheckDecisionOptions, type CheckDecisionRuleVersion, type CheckFactMethod, type CheckFactState, type CheckFreshnessFailure, type CheckMateriality, type CheckReasonCode, type CheckVerdict, } from "./decision.js";
export { parseVerificationKey, verificationKeyFromDocument, } from "./key-document.js";
export { isRfc3339Timestamp, parseRfc3339Instant, rfc3339TimestampMilliseconds, rfc3339TimestampNanoseconds, type Rfc3339Instant, } from "./rfc3339.js";
export { KAVAL_CANONICALIZATION, type FreshnessStatus, type JsonValue, type KeyLifecycle, type KeyLifecycleStatus, type VerificationKey, type VerificationResult, type VerifyOptions, } from "./types.js";
export { KAVAL_CANONICALIZATION, type FreshnessStatus, type JsonValue, type KeyLifecycle, type KeyLifecycleStatus, type VerificationDecision, type VerificationKey, type VerificationResult, type VerificationScope, type VerifyOptions, } from "./types.js";
export { extractReceipt, verifyReceipt, verifyReceiptText } from "./verify.js";
export { DEFAULT_WEBHOOK_TOLERANCE_SECONDS, verifyWebhookSignature, WEBHOOK_SIGNATURE_VERSION, WEBHOOK_SIGNED_CONTENT, type VerifyWebhookSignatureInput, type WebhookHeaderSource, type WebhookRejectionReason, type WebhookSignatureAccepted, type WebhookSignatureRejected, type WebhookSignatureResult, } from "./webhook.js";

@@ -6,6 +6,14 @@ /**

* no `node:https`, no sockets. It reads a receipt and a key document you already hold and answers
* three separate questions — is the Ed25519 signature over the exact canonical bytes, is the key
* trusted, and is the receipt fresh at the instant you name. That is the whole point of the
* subpath, and `test/verify/no-network.test.ts` holds the import graph to it.
* four separate questions — is the Ed25519 signature over the exact canonical bytes, is the key
* trusted, is the receipt fresh at the instant you name, and (only when you pass
* `derive_verdict: true`) does the receipt's stated ALLOW/REVIEW/BLOCK actually follow from the
* facts the receipt itself carries. That is the whole point of the subpath, and
* `test/verify/no-network.test.ts` holds the import graph to it.
*
* The fourth question is what makes a receipt an appeal packet rather than a signed assertion. The
* decision table it runs is `./decision.js` — `check-decision/1.1.0`, the same table Kaval's issuer
* executes, carried here so a holder never has to run Kaval's software to check Kaval's verdict.
* `decision.ts` is a mirrored copy of the issuer's; `test/verify/mirror-pin.test.ts` pins the shared
* conformance vectors that make the two answer identically.
*
* `verifyWebhookSignature` is here for the same reason: authenticating an inbound `fact_state.delta`

@@ -21,2 +29,3 @@ * is a pure HMAC over bytes you were handed, and a receiver that had to reach the network to decide

export { canonicalUnsignedReceiptBytes, canonicalUnsignedReceiptJson, MAX_JSON_NUMBER_CHARACTERS, parseJsonStrict, stableCanonicalJson, } from "./canonicalize.js";
export { CHECK_DECISION_RULE_VERSION, CHECK_DECISION_RULE_VERSIONS, CHECK_FACT_METHODS, CHECK_FACT_STATES, CHECK_FRESHNESS_FAILURES, CHECK_MATERIALITIES, CHECK_REASON_CODES, CHECK_VERDICTS, ReceiptNotSelfDerivableError, checkDecisionInputFromReceipt, decideCheck, deriveCheckDecision, } from "./decision.js";
export { parseVerificationKey, verificationKeyFromDocument, } from "./key-document.js";

@@ -23,0 +32,0 @@ export { isRfc3339Timestamp, parseRfc3339Instant, rfc3339TimestampMilliseconds, rfc3339TimestampNanoseconds, } from "./rfc3339.js";

@@ -0,1 +1,2 @@

import type { CheckDecision } from "./decision.js";
export declare const KAVAL_CANONICALIZATION: "kaval-stable-json-v1";

@@ -27,5 +28,41 @@ export type JsonPrimitive = null | boolean | number | string;

export type FreshnessStatus = "fresh" | "recheck_due" | "expired" | "not_yet_issued" | "unknown";
/**
* What a verification result actually covers, stated in the result itself.
*
* `signature_envelope` — the historical and still the DEFAULT answer: the Ed25519 signature over the
* canonical unsigned bytes, key trust, and timing. It deliberately says nothing about the verdict.
*
* `signature_envelope+decision_table` — the caller passed `derive_verdict: true`, so the result
* additionally re-derives the receipt's verdict from the receipt's own facts using the published
* `check-decision` table (see `decision.ts`) and compares it with the verdict the receipt states.
*
* Widening this union is additive by construction: a caller that never opts in never observes the
* second value, so `scope === "signature_envelope"` keeps meaning exactly what it always meant.
*/
export type VerificationScope = "signature_envelope" | "signature_envelope+decision_table";
/**
* The verdict half of a verification, present only when `derive_verdict: true` was requested.
*
* `derived` is computed from `facts[]` and `compilation_uncertain` ALONE. The receipt's own
* `decision` / `reason_codes` are reported as `stated` and are never an input — reading the answer
* off the answer would make the comparison vacuous. `matches` is the comparison, and it is the only
* field that may move `accepted`.
*/
export interface VerificationDecision {
/** The decision-table version this verifier executes. */
supported_rule_version: string;
/** The decision-table version the receipt names, when it names one. */
receipt_rule_version?: string;
stated?: {
verdict?: string;
reason_codes?: string[];
};
derived?: CheckDecision;
/** True only when a verdict AND its reason-code set were re-derived and both agree. */
matches: boolean;
error?: string;
}
export interface VerificationResult {
contract_version: "1";
scope: "signature_envelope";
scope: VerificationScope;
accepted: boolean;

@@ -69,5 +106,19 @@ format: {

};
/** Present only when `derive_verdict: true` was requested. Additive; see `VerificationScope`. */
decision?: VerificationDecision;
}
export interface VerifyOptions {
at?: Date | number | string;
/**
* Also re-derive the receipt's verdict from its own facts and require it to match.
*
* OFF BY DEFAULT, and that default is a compatibility guarantee, not timidity: turning it on
* changes `scope`, adds a `decision` block, and can turn a signature-valid result into
* `accepted: false`. Every caller written before this option existed keeps the exact result shape
* and the exact acceptance semantics it was written against.
*
* Only `POST /v1/check` receipts carry a fact list. A ProofPacket has none, so asking for
* re-derivation on one fails closed rather than pretending the question was answered.
*/
derive_verdict?: boolean;
}

@@ -10,6 +10,13 @@ import { type VerificationResult, type VerifyOptions } from "./types.js";

*
* This intentionally validates only the signature envelope. Full ProofPacket schema validation is
* the issuer/application's job and must not be confused with cryptographic verification.
* By default this validates only the SIGNATURE ENVELOPE — `scope: "signature_envelope"` — and says
* nothing about what the receipt concluded. Pass `derive_verdict: true` and it also re-derives the
* verdict from the receipt's own facts through the published `check-decision` table and requires the
* two to agree; `scope` then reads `"signature_envelope+decision_table"` and a `decision` block is
* present. The default is off so that every caller written before that option existed keeps its
* result shape and its acceptance semantics unchanged.
*
* Neither scope is full ProofPacket schema validation: that remains the issuer/application's job and
* must not be confused with cryptographic verification.
*/
export declare function verifyReceipt(receiptValue: unknown, keyDocument: unknown, options?: VerifyOptions): VerificationResult;
export declare function verifyReceiptText(receiptJson: string, keyDocumentJson: string, options?: VerifyOptions): VerificationResult;
import { createHash, createPublicKey, verify as verifySignature, } from "node:crypto";
import { canonicalUnsignedReceiptBytes, parseJsonStrict, } from "./canonicalize.js";
import { CHECK_DECISION_RULE_VERSION, CHECK_DECISION_RULE_VERSIONS, ReceiptNotSelfDerivableError, deriveCheckDecision, } from "./decision.js";
import { decodeCanonicalBase64Url, verificationKeyFromDocument, } from "./key-document.js";

@@ -125,6 +126,106 @@ import { parseRfc3339Instant } from "./rfc3339.js";

}
function malformedResult(receipt, at, error) {
/* ------------------------------------------------------------------ *
* Verdict re-derivation (opt-in; see VerifyOptions.derive_verdict) *
* ------------------------------------------------------------------ */
function scopeFor(derive) {
return derive ? "signature_envelope+decision_table" : "signature_envelope";
}
function statedDecision(receipt) {
const codes = receipt["reason_codes"];
const stated = {
...(typeof receipt["decision"] === "string"
? { verdict: receipt["decision"] }
: {}),
...(Array.isArray(codes) && codes.every((code) => typeof code === "string")
? { reason_codes: [...codes] }
: {}),
};
return Object.keys(stated).length === 0 ? undefined : stated;
}
/** Set equality: the codes are what carry meaning, their array order is only determinism. */
function sameCodes(stated, derived) {
const left = [...stated].sort();
const right = [...derived].sort();
return (left.length === right.length &&
left.every((code, index) => code === right[index]));
}
/**
* Re-derive the receipt's verdict from its own facts and compare with what it claims.
*
* Every exit that is not a clean match sets `matches: false`, because a verifier that could not
* answer the question must never read as one that answered it favourably. A receipt decided under a
* decision-table version this build does not implement is refused for that reason and named: it is
* not evidence of tampering, and pretending to re-derive it across a rule change would be worse than
* saying so.
*/
function decisionResult(receipt) {
const supported = { supported_rule_version: CHECK_DECISION_RULE_VERSION };
if (receipt === null) {
return {
...supported,
matches: false,
error: "receipt is not a JSON object",
};
}
const ruleVersion = receipt["decision_rule_version"];
const stated = statedDecision(receipt);
const head = {
...supported,
...(typeof ruleVersion === "string"
? { receipt_rule_version: ruleVersion }
: {}),
...(stated === undefined ? {} : { stated }),
matches: false,
};
if (typeof ruleVersion !== "string") {
return { ...head, error: "receipt names no decision rule version" };
}
if (!CHECK_DECISION_RULE_VERSIONS.includes(ruleVersion)) {
return {
...head,
error: `receipt was decided under ${ruleVersion}, which this verifier does not implement`,
};
}
let derived;
try {
derived = deriveCheckDecision(receipt);
}
catch (error) {
return {
...head,
error: error instanceof ReceiptNotSelfDerivableError
? error.message
: `verdict re-derivation failed: ${error.message}`,
};
}
if (stated?.verdict === undefined || stated.reason_codes === undefined) {
return {
...head,
derived,
error: "receipt states no verdict to compare against",
};
}
if (stated.verdict !== derived.verdict) {
return {
...head,
derived,
error: `receipt states ${stated.verdict} but its own facts re-derive as ${derived.verdict}`,
};
}
if (!sameCodes(stated.reason_codes, derived.reason_codes)) {
return {
...head,
derived,
error: `receipt states reason codes [${stated.reason_codes.join(", ")}] but its own facts re-derive [${derived.reason_codes.join(", ")}]`,
};
}
return { ...head, derived, matches: true };
}
function decisionFields(receipt, derive) {
return derive ? { decision: decisionResult(receipt) } : {};
}
function malformedResult(receipt, at, error, derive = false) {
return {
contract_version: "1",
scope: "signature_envelope",
scope: scopeFor(derive),
accepted: false,

@@ -152,2 +253,3 @@ format: { valid: false, error },

: { status: "unknown", evaluated_at: at.iso },
...decisionFields(receipt, derive),
};

@@ -158,19 +260,27 @@ }

*
* This intentionally validates only the signature envelope. Full ProofPacket schema validation is
* the issuer/application's job and must not be confused with cryptographic verification.
* By default this validates only the SIGNATURE ENVELOPE — `scope: "signature_envelope"` — and says
* nothing about what the receipt concluded. Pass `derive_verdict: true` and it also re-derives the
* verdict from the receipt's own facts through the published `check-decision` table and requires the
* two to agree; `scope` then reads `"signature_envelope+decision_table"` and a `decision` block is
* present. The default is off so that every caller written before that option existed keeps its
* result shape and its acceptance semantics unchanged.
*
* Neither scope is full ProofPacket schema validation: that remains the issuer/application's job and
* must not be confused with cryptographic verification.
*/
export function verifyReceipt(receiptValue, keyDocument, options = {}) {
const at = evaluatedAt(options.at);
const derive = options.derive_verdict === true;
const receipt = record(receiptValue);
if (!receipt)
return malformedResult(null, at, "receipt must be a JSON object");
return malformedResult(null, at, "receipt must be a JSON object", derive);
const signature = record(receipt["signature"]);
if (!signature)
return malformedResult(receipt, at, "receipt signature is missing");
return malformedResult(receipt, at, "receipt signature is missing", derive);
if (!signatureFieldSetIsValid(signature)) {
return malformedResult(receipt, at, "receipt signature has an invalid field set");
return malformedResult(receipt, at, "receipt signature has an invalid field set", derive);
}
if (Object.hasOwn(signature, "signed_at") &&
timestamp(signature["signed_at"]) === null) {
return malformedResult(receipt, at, "receipt signature signed_at is not an RFC 3339 instant");
return malformedResult(receipt, at, "receipt signature signed_at is not an RFC 3339 instant", derive);
}

@@ -195,11 +305,11 @@ /*

if (signature["signed_at"] !== receipt["checked_at"]) {
return malformedResult(receipt, at, "receipt signature signed_at does not match the signed checked_at");
return malformedResult(receipt, at, "receipt signature signed_at does not match the signed checked_at", derive);
}
}
if (signature["algorithm"] !== "Ed25519") {
return malformedResult(receipt, at, "only Ed25519 receipt signatures are supported");
return malformedResult(receipt, at, "only Ed25519 receipt signatures are supported", derive);
}
const keyId = signature["key_id"];
if (typeof keyId !== "string" || !keyId.trim() || keyId.length > 128) {
return malformedResult(receipt, at, "receipt signature key_id is invalid");
return malformedResult(receipt, at, "receipt signature key_id is invalid", derive);
}

@@ -211,3 +321,3 @@ let signatureBytes;

catch (error) {
return malformedResult(receipt, at, error.message);
return malformedResult(receipt, at, error.message, derive);
}

@@ -219,3 +329,3 @@ let canonicalBytes;

catch (error) {
return malformedResult(receipt, at, error.message);
return malformedResult(receipt, at, error.message, derive);
}

@@ -243,3 +353,3 @@ const digest = createHash("sha256").update(canonicalBytes).digest("hex");

contract_version: "1",
scope: "signature_envelope",
scope: scopeFor(derive),
accepted: false,

@@ -261,2 +371,3 @@ format: { valid: true },

freshness: freshness(receipt, at),
...decisionFields(receipt, derive),
};

@@ -267,3 +378,3 @@ }

contract_version: "1",
scope: "signature_envelope",
scope: scopeFor(derive),
accepted: false,

@@ -285,2 +396,3 @@ format: { valid: true },

freshness: freshness(receipt, at),
...decisionFields(receipt, derive),
};

@@ -337,6 +449,16 @@ }

}
/*
* The verdict half, when it was asked for.
*
* It is computed AFTER the signature so the two answers stay separable in the result — a holder
* must be able to see "the bytes are authentic but the verdict does not follow from them" as a
* distinct outcome from "these bytes were never signed by us". It gates `accepted` in one
* direction only: it can refuse, never rescue. A receipt whose signature fails is not saved by
* re-deriving cleanly.
*/
const decision = derive ? decisionResult(receipt) : undefined;
return {
contract_version: "1",
scope: "signature_envelope",
accepted: cryptographicallyValid && trusted,
scope: scopeFor(derive),
accepted: cryptographicallyValid && trusted && (decision?.matches ?? true),
format: { valid: true },

@@ -365,2 +487,3 @@ receipt: receiptIdentity,

freshness: freshness(receipt, at),
...(decision === undefined ? {} : { decision }),
};

@@ -370,2 +493,4 @@ }

const at = evaluatedAt(options.at);
const derive = options.derive_verdict === true;
const forward = { at: at.iso, ...(derive ? { derive_verdict: true } : {}) };
let receipt;

@@ -376,3 +501,3 @@ try {

catch (error) {
return malformedResult(null, at, `receipt JSON is invalid: ${error.message}`);
return malformedResult(null, at, `receipt JSON is invalid: ${error.message}`, derive);
}

@@ -385,3 +510,3 @@ let keyDocument;

const message = `verification key JSON is invalid: ${error.message}`;
const result = verifyReceipt(receipt, {}, { at: at.iso });
const result = verifyReceipt(receipt, {}, forward);
return {

@@ -400,3 +525,3 @@ ...result,

}
return verifyReceipt(receipt, keyDocument, { at: at.iso });
return verifyReceipt(receipt, keyDocument, forward);
}
{
"name": "@usekaval/kaval",
"version": "0.7.0",
"version": "0.7.1",
"license": "Apache-2.0",

@@ -5,0 +5,0 @@ "description": "Before an AI agent acts, Kaval verifies the facts the action depends on and returns ALLOW, REVIEW, or BLOCK with an Ed25519-signed receipt.",

@@ -164,4 +164,4 @@ # @usekaval/kaval

`verifyReceipt` answers three questions **separately**, and conflating them is the mistake it exists
to prevent:
`verifyReceipt` answers three questions by default. It answers a fourth question when you set
`derive_verdict: true`:

@@ -172,2 +172,3 @@ 1. **Cryptographic validity** — does the Ed25519 signature cover the exact canonical unsigned bytes?

`not_yet_issued`?
4. **Verdict derivation** — does the receipt's fact list produce its stated verdict and reason codes?

@@ -189,3 +190,6 @@ A valid signature proves who sealed these exact bytes. It does not prove the claim is true, that its

const result = verifyReceipt(receipt, keyset, { at: "2026-07-20T12:00:00.000Z" });
const result = verifyReceipt(receipt, keyset, {
at: "2026-07-20T12:00:00.000Z",
derive_verdict: true,
});

@@ -196,3 +200,4 @@ result.cryptographic.valid; // the bytes really were signed by this key

result.freshness.status; // "fresh" | "recheck_due" | "expired" | "not_yet_issued" | "unknown"
result.accepted; // cryptographic.valid && key.trusted
result.decision?.matches; // the published table reproduced the verdict and reason codes
result.accepted; // the signature, key trust, and requested verdict derivation passed
```

@@ -205,2 +210,3 @@

Exported: `verifyReceipt` · `verifyReceiptText` · `extractReceipt` · `verifyWebhookSignature` ·
`decideCheck` · `deriveCheckDecision` · `checkDecisionInputFromReceipt` ·
`parseJsonStrict` · `stableCanonicalJson` · `canonicalUnsignedReceiptJson` ·

@@ -257,2 +263,3 @@ `canonicalUnsignedReceiptBytes` · `parseVerificationKey` · `verificationKeyFromDocument` ·

--require-fresh Exit non-zero unless freshness is "fresh"
--derive-verdict Re-derive the check verdict and require it to match
--allow-http-loopback Permit http://localhost/127.0.0.0/8/::1 for local development

@@ -265,5 +272,6 @@ --compact Emit compact JSON

**Exit `0`** means the signature is valid and the key is trusted — an expired receipt still exits
`0`, because freshness is a different question; add `--require-fresh` to make anything but `fresh`
exit non-zero. **Exit `1`** is a completed verification that was not accepted. **Exit `2`** is an
input, I/O, or discovery failure.
`0`, because freshness is a different question. Add `--require-fresh` to require fresh evidence.
Add `--derive-verdict` to require the stated verdict to match the published decision table.
**Exit `1`** is a completed verification that was not accepted. **Exit `2`** is an input, I/O, or
discovery failure.

@@ -270,0 +278,0 @@ `--at` and every receipt/key timestamp must be a component-valid RFC 3339 instant. Date-only