// KSL tender events: an append only, validated JSONL stream of every tender step, and a daily report section.
// emit(event) appends one line; reportSection(events, day) returns Markdown. No secrets: unknown fields are refused.
import { createHash } from 'node:crypto';
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
export const STEPS = ['listing', 'airlock_decision', 'bid', 'selection', 'award', 'delivery', 'review', 'payment'] as const;
export type Step = (typeof STEPS)[number];
interface Base { job_id: string; at: string }
export type TenderEvent =
| (Base & { type: 'listing'; title?: string; ceiling_usdc?: number })
| (Base & { type: 'airlock_decision'; bid_id: string; decision: 'pass' | 'block'; note?: string })
| (Base & { type: 'bid'; bid_id: string; agent_did: string; price_usdc: number })
| (Base & { type: 'selection'; bid_id: string })
| (Base & { type: 'award'; bid_id: string })
| (Base & { type: 'delivery'; bid_id: string; url: string })
| (Base & { type: 'review'; bid_id: string; verdict: 'accepted' | 'rejected'; note?: string })
| (Base & { type: 'payment'; bid_id: string; amount_usdc: number; rail?: 'x402' });
/** One stored line: the event plus its place in the chain. */
export type StoredEvent = TenderEvent & { seq: number; prev: string; hash: string };
type FieldKind = 'id' | 'did' | 'usdc' | 'url' | 'text' | ['enum', ...string[]];
const FIELDS: Record<Step, Record<string, [FieldKind, boolean]>> = {
listing: { title: ['text', false], ceiling_usdc: ['usdc', false] },
airlock_decision: { bid_id: ['id', true], decision: [['enum', 'pass', 'block'], true], note: ['text', false] },
bid: { bid_id: ['id', true], agent_did: ['did', true], price_usdc: ['usdc', true] },
selection: { bid_id: ['id', true] },
award: { bid_id: ['id', true] },
delivery: { bid_id: ['id', true], url: ['url', true] },
review: { bid_id: ['id', true], verdict: [['enum', 'accepted', 'rejected'], true], note: ['text', false] },
payment: { bid_id: ['id', true], amount_usdc: ['usdc', true], rail: [['enum', 'x402'], false] },
};
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
export class EventError extends Error {}
/** Throws EventError naming the first problem; returns the event unchanged when it is valid. */
export function validate(e: unknown): TenderEvent {
if (!e || typeof e !== 'object' || Array.isArray(e)) throw new EventError('event must be an object');
const ev = e as Record<string, unknown>;
if (!STEPS.includes(ev.type as Step)) throw new EventError(`type must be one of ${STEPS.join(', ')}`);
const spec = FIELDS[ev.type as Step];
if (typeof ev.job_id !== 'string' || !ID_RE.test(ev.job_id)) throw new EventError('job_id is missing or malformed');
if (typeof ev.at !== 'string' || !ISO_RE.test(ev.at) || !Number.isFinite(Date.parse(ev.at))) throw new EventError('at must be an ISO date-time with a zone');
for (const k of Object.keys(ev)) if (!(k in spec) && !['type', 'job_id', 'at'].includes(k)) throw new EventError(`unknown field ${k} on ${ev.type}`);
for (const [k, [kind, required]] of Object.entries(spec)) {
const v = ev[k];
if (v === undefined) {
if (required) throw new EventError(`${ev.type} needs ${k}`);
continue;
}
const bad = () => new EventError(`${k} on ${ev.type} is not valid`);
if (Array.isArray(kind)) {
if (!kind.slice(1).includes(v as string)) throw bad();
} else if (kind === 'id') {
if (typeof v !== 'string' || !ID_RE.test(v)) throw bad();
} else if (kind === 'did') {
if (typeof v !== 'string' || !/^did:[a-z0-9]+:.{1,200}$/.test(v)) throw bad();
} else if (kind === 'usdc') {
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1e9) throw bad();
} else if (kind === 'url') {
if (typeof v !== 'string' || !/^https:\/\/\S+$/.test(v)) throw bad();
} else if (typeof v !== 'string' || v.length > 500) throw bad();
}
return ev as unknown as TenderEvent;
}
const GENESIS = '0'.repeat(64);
const canonical = (o: Record<string, unknown>) => JSON.stringify(Object.keys(o).sort().reduce<Record<string, unknown>>((a, k) => ((a[k] = o[k]), a), {}));
const hashOf = (event: TenderEvent, seq: number, prev: string) => createHash('sha256').update(`${seq}\n${prev}\n${canonical(event as unknown as Record<string, unknown>)}`).digest('hex');
/** Reads a JSONL stream and checks the chain: every line in order, none changed, none removed. */
export function readStream(path: string): StoredEvent[] {
if (!existsSync(path)) return [];
const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.trim());
let prev = GENESIS;
return lines.map((line, i) => {
let s: StoredEvent;
try {
s = JSON.parse(line);
} catch {
throw new EventError(`line ${i + 1} is not JSON`);
}
const { seq, prev: p, hash, ...event } = s;
if (seq !== i + 1 || p !== prev) throw new EventError(`line ${i + 1} is out of order or a line before it was removed`);
validate(event);
if (hash !== hashOf(event as TenderEvent, seq, p)) throw new EventError(`line ${i + 1} was changed after it was written`);
prev = hash;
return s;
});
}
export interface EventLog {
emit(event: TenderEvent): StoredEvent;
read(): StoredEvent[];
readonly path: string;
}
/** Opens (or creates) an append only stream. The file is only ever appended to. */
export function openEventLog(path: string): EventLog {
let last = readStream(path);
let seq = last.length;
let prev = seq ? last[seq - 1].hash : GENESIS;
last = [];
return {
path,
emit(event) {
const ev = validate(structuredClone(event));
const s = seq + 1;
const hash = hashOf(ev, s, prev);
const stored = { ...ev, seq: s, prev, hash } as StoredEvent;
appendFileSync(path, JSON.stringify(stored) + '\n', { flag: 'a' });
seq = s;
prev = hash;
return stored;
},
read: () => readStream(path),
};
}
let defaultLog: EventLog | null = null;
/** emit(event) on the stream named by KSL_EVENTS_PATH (default ./tender-events.jsonl). */
export function emit(event: TenderEvent): StoredEvent {
const path = process.env.KSL_EVENTS_PATH ?? 'tender-events.jsonl';
if (!defaultLog || defaultLog.path !== path) defaultLog = openEventLog(path);
return defaultLog.emit(event);
}
const LABEL: Record<Step, string> = {
listing: 'Listings', airlock_decision: 'Airlock decisions', bid: 'Bids', selection: 'Selections', award: 'Awards', delivery: 'Deliveries', review: 'Reviews', payment: 'Payments',
};
const dayOfIso = (at: string) => new Date(Date.parse(at)).toISOString().slice(0, 10);
const money = (n: number) => (Math.round(n * 100) / 100).toFixed(2);
export interface Anomaly { job_id: string; bid_id: string; problem: string }
/** Anomalies in every event up to the end of the day (UTC), so a decision made on an earlier day counts. */
export function anomalies(events: TenderEvent[], day?: string): Anomaly[] {
const until = day ? Date.parse(`${day}T23:59:59.999Z`) : Infinity;
const evs = events.filter((e) => Date.parse(e.at) <= until);
const key = (e: { job_id: string; bid_id: string }) => `${e.job_id}\u0000${e.bid_id}`;
const by = new Map<Step, Map<string, TenderEvent>>(STEPS.map((s) => [s, new Map()]));
for (const e of evs) if ('bid_id' in e) by.get(e.type)!.set(key(e), e);
const out: Anomaly[] = [];
const add = (e: TenderEvent & { bid_id: string }, problem: string) => out.push({ job_id: e.job_id, bid_id: e.bid_id, problem });
for (const e of evs) {
if (!('bid_id' in e)) continue;
const k = key(e);
const airlock = by.get('airlock_decision')!.get(k) as Extract<TenderEvent, { type: 'airlock_decision' }> | undefined;
const review = by.get('review')!.get(k) as Extract<TenderEvent, { type: 'review' }> | undefined;
if (e.type === 'bid' && !airlock) add(e, 'bid without an airlock decision');
if (e.type === 'selection' && airlock?.decision === 'block') add(e, 'selection of a bid the airlock blocked');
if (e.type === 'selection' && !by.get('bid')!.has(k)) add(e, 'selection of a bid that was never recorded');
if (e.type === 'award' && !by.get('selection')!.has(k)) add(e, 'award without a selection');
if (e.type === 'delivery' && !by.get('award')!.has(k)) add(e, 'delivery without an award');
if (e.type === 'review' && !by.get('delivery')!.has(k)) add(e, 'review without a delivery');
if (e.type === 'payment' && review?.verdict !== 'accepted') add(e, review ? 'payment after a rejected review' : 'payment without acceptance');
if (e.type === 'payment') {
const bid = by.get('bid')!.get(k) as Extract<TenderEvent, { type: 'bid' }> | undefined;
if (bid && e.amount_usdc > bid.price_usdc) add(e, `payment of ${money(e.amount_usdc)} USDC is over the bid price of ${money(bid.price_usdc)} USDC`);
}
}
const seen = new Set<string>();
return out
.filter((a) => (seen.has(JSON.stringify(a)) ? false : (seen.add(JSON.stringify(a)), true)))
.sort((a, b) => (a.job_id + a.bid_id + a.problem < b.job_id + b.bid_id + b.problem ? -1 : 1));
}
/** The daily report section: counts of each step on that day (UTC), USDC paid, and the open anomalies. */
export function reportSection(events: TenderEvent[], day: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) throw new EventError('day must be YYYY-MM-DD');
const today = events.filter((e) => dayOfIso(e.at) === day);
const count = (s: Step) => today.filter((e) => e.type === s).length;
const blocked = today.filter((e) => e.type === 'airlock_decision' && e.decision === 'block').length;
const rejected = today.filter((e) => e.type === 'review' && e.verdict === 'rejected').length;
const paid = today.reduce((t, e) => t + (e.type === 'payment' ? e.amount_usdc : 0), 0);
const jobs = new Set(today.map((e) => e.job_id)).size;
const lines = [
`### Tenders on ${day}`,
'',
`${today.length} events across ${jobs} ${jobs === 1 ? 'job' : 'jobs'}; ${money(paid)} USDC paid.`,
'',
'| Step | Count |',
'| --- | --- |',
...STEPS.map((s) => {
const extra = s === 'airlock_decision' && blocked ? ` (${blocked} blocked)` : s === 'review' && rejected ? ` (${rejected} rejected)` : '';
return `| ${LABEL[s]} | ${count(s)}${extra} |`;
}),
'',
];
const found = anomalies(events, day);
if (!found.length) lines.push('No open anomalies at the end of the day.');
else {
lines.push(`**Open anomalies at the end of the day (${found.length})**`, '');
for (const a of found) lines.push(`- ${a.job_id} / ${a.bid_id}: ${a.problem}`);
}
return lines.join('\n') + '\n';
}
agentgate-source: code mirror on Cloudflare Pages. All repos