Drive Routrel Audit from your own code
The page reads the Kotlin behind one Ktor service in the browser for free — the Application
module and its plugins, the routes, the services, the Exposed tables, entities and repositories and the
database setup (a build.gradle.kts or a migration .sql may be among them),
one or more files joined into one paste, each introduced by a // file: <path> line
— with its line numbers. It lexes Kotlin strings, string templates, raw strings, char literals,
backtick names and nested comments out first, rebuilds the brace structure (classes, funs, lambdas,
loops, try/catch), and inventories the service: every route with its full path and the
authenticate block around it (following Route extension funs to where
routing mounts them), every install(...) and what it is configured with,
every JWT verifier, every Exposed transaction with the fun it is in, the thread it runs on and what it
writes, and every table and entity. Then it runs 22 deterministic checks drawn from Ktor and Exposed
practice: server ids KT-01..KT-10 (runBlocking or Thread.sleep in a
handler, a state-changing route outside every authenticate { }, CORS
anyHost(), a StatusPages handler that sends the exception’s message, a JWT verifier
that checks neither audience nor issuer, !! on a request parameter or principal, a
parameter parsed with nothing that turns a bad value into 400, call.respond in an
if with no return, call.receive with no ContentNegotiation, no StatusPages),
data ids EX-01..EX-10 (a blocking transaction { } run from coroutine code, SQL text built
from values, a LIKE pattern built from unescaped input, one operation split across several
transactions, a DAO entity returned out of its transaction, a query, insert or transaction inside a
loop, Database.connect with no pool, SchemaUtils.create at startup,
update { } with no where, single() or first() on a query that
can find nothing), and KV-01 (a credential literal), plus KV-00 for context. Nothing is compiled or
run: every flag is a reading of the text. Its three paid steps are available over HTTP. You send one
JSON object with task set to "server", "data" or
"fix", the paste as code and the page’s check as a facts
string, and the model replies with one JSON object: an audit that rates the lane’s areas,
answers every flag of the lane and gives exact before/after edits per finding, or the whole fixed
file(s) with a list of every change tied to the page’s flags.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. The paths below (/guest,
/me, /estimate, /run, /jobs/{job_id},
/run-stream) are relative to it, exactly as sdk.js calls them. A
plain-text summary of the app for agents is at /llms.txt. All three lanes run
on the gpt-terra model with a 10% publisher markup. A run needs a signed-in (user) token
and is metered: the platform places a hold on your credits when the run starts (the
estimate’s hold_credits, a reservation) and then charges only what
the run used (the job’s charged_credits). /estimate is free.
The response envelope
Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s
SDK (sdk.js) decides success from the HTTP status, not from ok: on a
2xx it returns data; otherwise it raises an error whose message is
error.message (or the HTTP status text when the body carries none), with
status, code (error.code) and details
(error.details) attached. A body that is not JSON is treated as {}.
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
Error codes
Branch on the HTTP status and error.code, show error.message. The page
handles 401 and 402 by name; the rest reach it as a generic failure.
| HTTP | error.code | What it means / what to do |
|---|---|---|
400 | VALIDATION_ERROR | The body is not a JSON object, a required field (task, code, facts) is missing, or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object is the usual cause. |
401 | UNAUTHORIZED | No token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token. |
402 | INSUFFICIENT_CREDITS | The balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits. |
404 | NOT_FOUND | A wrong path, or a job id that does not belong to this token. |
409 | CONFLICT | An Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry). |
429 | RATE_LIMITED | Too many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key. |
Failures that do not arrive as an HTTP status:
| What | What happens |
|---|---|
event: error on /run-stream | The SDK raises an error carrying the event’s message, code and job_id. |
Job status: "failed" | Polling stops; the job’s error field says why. |
| Polling past 180 s | The SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled. |
The task field: three lanes
task routes the run, and it takes exactly three values: "server",
"data" and "fix". The two audits share one reply shape (with different areas
and catalogue ids), the fix lane has its own, and a reply never mixes two contracts. The skill’s
instructions tell the model to do the server lane when task is missing or anything else
(and the page’s own Ktorkit.buildInput turns any lane it does not know into
"server"), so a typo silently gets you a server audit: always send one of the three
values.
task | What it does | verdict | Use it when |
|---|---|---|---|
"server" | Reviews the Ktor server the way a senior engineer reviews it before it takes traffic: which routes anyone can call, what a malformed or hostile request does, what the client sees when something fails, and what blocks the threads serving calls. Rates four areas, in this order: auth (authenticate coverage, the JWT verifier and validate, the principal and ownership checks, CORS), handlers (early responses and returns, parameters and bodies, !!, ContentNegotiation), errors (StatusPages mapping, what reaches the client) and threads (runBlocking, Thread.sleep and blocking calls in handlers), each sound, fragile or broken. Confirms or dismisses every server flag (KT-01..KT-10, KV-01), adds what text rules cannot see (a route that lets one user act on another user’s resource, input used without validation, a public route that returns more than it should), and gives the verbatim evidence and exact before/after edits per finding, with a plan that ends in a testApplication { } test. | blocked | needs_work | ready | Before the service takes traffic: auth, request handling, error responses and threads. |
"data" | Reviews the Exposed data layer the way a senior engineer reviews it before it holds production data: what runs in which transaction, what a concurrent request can do between two steps, what SQL the queries become, and how connections and the schema are managed. Rates three areas, in this order: transactions (blocking vs suspended transactions, one transaction per operation, locking, entities that escape), queries (values in SQL text, LIKE patterns, N+1, update without where, single/first) and setup (connection pool, migrations, SchemaUtils). Confirms or dismisses every data flag (EX-01..EX-10, KV-01), with the same findings, edits and plan as the server audit, ending in a repository test against a real Postgres. | blocked | needs_work | ready | When requests stall under load, balances or counts go wrong under concurrency, or the database shows more connections than expected. |
"fix" | Writes the fixed version of the pasted code: the whole of every file it changes (at most one new file next to them), plus a changes list tied to the page flags, the verify steps (./gradlew build, and the testApplication and repository tests to write) and what it left open. It closes every high and medium flag of both catalogues unless the note asks to leave something as it is, or the fix needs a change the signature rule forbids: every fun that is not private keeps its name, its parameters, its return type and whether it is suspend, unless the note allows more. | fixed | partial | When you want the code changed, not a list of findings: a service you already know is broken, or what is left after an audit. |
The page chains the lanes: after either audit, “Apply every edit and fix the rest” puts
every audit edit into the paste, switches to the fix lane on that edited paste and adds an
audit_handoff string (below) with the audit’s lane, verdict, headline and findings;
after a server audit, “Apply every edit and audit the data layer” puts the server edits
into the paste and selects the data audit instead (no handoff string: it is a plain data run on the
edited paste). Over the API a handoff is a second run with task: "fix", the edited paste
as code, facts recomputed from the edited paste for the fix lane, and that
string. The page drops the handoff as soon as the paste is edited again.
Guards the page applies before a run and the API does not: it refuses to price or run a paste it cannot read (“Nothing was pasted.”, “No Kotlin file was found. Paste .kt source, with a // file: <path>.kt line above each file.”, or “This does not read as Kotlin source: no fun, class, object, import or package was found.”), and it warns before a run on a paste with little to review in that lane (“No route and no install(...) were found: there may be little Ktor server code here to audit.”, “No Exposed transaction, table or Database.connect was found: there may be little data-layer code here to audit.”, or “No Ktor route and no Exposed code were found: there may be little here to fix.”).
Input fields
This is exactly what the page sends (ktorkit.js buildInput(lane, A, note,
handoff), checked by mustBeObject; app.js currentInput
and run), and what input-schema.json declares. The platform’s input
schema is scalar-only, so every value is a string: {task, code, facts, note?, audit_handoff?,
retry_note?}. buildInput emits them in the order task,
code, facts, note, audit_handoff:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "server", "data" or "fix" (the lanes above). |
code | string | yes | The paste, each file introduced by a // file: <path> line (# file: <path> for a .toml file); line 1 is its first line. Every line in an audit reply is counted in this text. A large paste is cut on whole files, with a // [routrel-audit: not sent: ...] line at the end (below). |
facts | string | yes | The page’s free check for this lane, Ktorkit.facts(A, lane), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. The input schema declares it required, so it must be sent; sending the object itself is a 400. The model is told to parse it. |
audit_handoff | string | no | fix lane only: a JSON string built from a server or data audit reply on this code (recon.js fixHandoff, below). buildInput drops it for the two audits. |
note | string | no | What the engineer says: the goal, a constraint (“keep the repository’s API”, “no new dependency”), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against code and answers it in summary. |
retry_note | string | no | Sent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it. |
mustBeObject only insists on a plain object that is not wrapped in
{"input": ...}; it is buildInput that leaves a blank note out, which is why
an empty note is simply absent.
The code string
code is the paste itself. A line // file: <path> (or
# file: <path>) starts each file when the path holds a . or a
/, and a --- line just before it only separates files. Files dropped on the
page (or a dropped source folder) are joined that way by Ktorkit.joinFiles, the build file
first, then migration .sql files, then Application.kt, then the rest by path:
each file under its marker line (# file: for a .versions.toml, //
file: otherwise), trailing whitespace removed, joined by newlines, ending in a newline. A paste
with no file line is read as one file, Main.kt (or build.gradle.kts when it
reads as a build script). The page normalises line endings to \n. A
build.gradle(.kts), settings.gradle(.kts) or .versions.toml
path is read as gradle (for the Ktor, Exposed and Kotlin versions and the artifacts), a
.kt or .kts path as kotlin; anything else, a migration
.sql included, is other: it is sent and the model may read it, but the checks
do not parse it. Send the text you want the reply to quote: every file in an audit reply
must be a path from facts.files, every line must fall inside that
file, every evidence must be copied from it, and every edit’s before
must occur in it exactly once.
The page reads up to 400 000 characters (MAX_TEXT, cut at a line) and 40 files
(MAX_FILES), and sends at most 60 000 characters (MAX_SEND): past that
buildInput drops whole files from the end — the last file that is neither Kotlin
nor Gradle first, then the last file — until the rest fits, cuts a single remaining file at a
line (“the end of <path>”) if it still does not, and ends the string with one line
naming what it left out:
// [routrel-audit: not sent: src/main/resources/db/migration/V7__audit_log.sql, src/main/kotlin/com/acme/AdminRoutes.kt]
The model is told never to quote or rewrite what was not sent, and facts.notes gets the
sentence “Not sent (the paste is over 60,000 characters): ... The page checked them; the run
did not see them.” facts is still computed from the whole paste. The worked
examples below are small, so their code is the paste unchanged.
The facts string
facts is required by the input schema: every run carries it as a string. The browser
computes it for free with the page’s check: Ktorkit.analyze splits the paste into
files, lexes them, rebuilds their brace structure, resolves every route’s full path and
authenticate block, reads the plugins, JWT verifiers, transactions, tables and entities
and runs the rules, and Ktorkit.facts(A, lane) turns that analysis into the object below.
It is cut per lane: the server audit gets the server flags and the shared ones (KV-01,
KV-00), the data audit the data flags and the shared ones, the fix lane all of them, and
lane and verdict_candidate follow the lane. The flag uids
(F1, F2, ...) are numbered over the whole paste, so a lane’s list can
skip numbers. It is how the model knows which flags to answer, which file paths and line ranges to
name, and which funs must survive. Its top-level keys, in the order built:
| Key | Contents |
|---|---|
lane | The lane these facts were cut for: server, data or fix. Send it with the same task. |
build | What a pasted build file declares: kotlin, ktor, exposed (versions, or null), artifacts, gradle_pasted; and counts: kotlin_files, functions, suspend_funs, routes, transactions, and module_pasted (an Application module with its install(...) calls, and every configure fun it calls, is in the paste). |
files | Every file: path (use it verbatim), kind (kotlin, gradle, other), line and end_line (the paste lines of the file’s content, without its marker line). |
functions | Every fun (up to 200): name (Class.fun for members, Receiver.fun for extension funs such as Route.slotRoutes), file, line, suspend, private. |
routes | Every route handler (up to 150): method, path (the full path, prefixes included), file, line, func, auth (the authenticate provider names around it, or null), mounted (false when its Route fun is never mounted in the paste: its prefix and its authenticate block are then unknown), and where they apply optional_auth, receives (the types it reads with call.receive) and via (where its Route fun is mounted). |
plugins | Every install(...) (up to 40): name, file, line, func, and for CORS any_host, allow_credentials and hosts. |
jwt | Every jwt provider (up to 10): name, file, line, verifier, audience, issuer (true, false, or null when the verifier is built outside the paste), validate. |
transactions | Every Exposed transaction (up to 150): file, line, func, kind (transaction, newSuspendedTransaction ...), blocking, coroutine (it runs in coroutine code), dispatcher, writes, queries, and nested when it is. |
tables, entities | Every Exposed table object (name, base, sql_name, file, line) and DAO entity class (name, base, file, line), up to 60 each. |
flags | Every flag of the lane: uid, id (KT-01..KT-10, EX-01..EX-10, KV-01, KV-00), sev (high | medium | low | info), file, line, text, detail, and lines (every line an aggregated flag covers, only when it covers more than one). An audit must answer every non-info flag by its uid; a fix must close every high and medium flag or name it in open. |
verdict_candidate | The verdict the lane’s flags alone point at: blocked with any high flag, needs_work with any medium, otherwise ready. |
notes | Plain sentences about what was not read or not sent (empty for a paste that fits). |
The simplest source is ktorkit.js itself: the page loads it from
/ktorkit.js, and it also runs in Node (it sets module.exports).
This script prints what the page would send as task, code and
facts for one lane (with the 60 000-character cut applied), with the facts decoded so you
can read them; the step 4 samples compact them back into the page’s string byte for byte. Save
the paste as bookings-paste.txt and redirect the output into
bookings-check.json:
// build-check.js -- run: node build-check.js server bookings-paste.txt > bookings-check.json (ktorkit.js saved next to it)
const fs = require("fs");
const Ktorkit = require("./ktorkit.js");
const lane = process.argv[2]; // server | data | fix: facts are cut per lane
if (Ktorkit.LANES.indexOf(lane) === -1) { console.error("lane must be server, data or fix"); process.exit(1); }
const A = Ktorkit.analyze(fs.readFileSync(process.argv[3], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Ktorkit.buildInput(lane, A, "", null);
console.log(JSON.stringify({ task: lane, code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const c = A.laneCounts[lane], real = A.flags.filter((f) => Ktorkit.inLane(f, lane) && f.sev !== "info");
console.error(lane + ": " + A.files.length + " file(s), " + A.functions.length + " fun(s), " + A.routes.length + " route(s), " + A.transactions.length + " transaction(s); " +
real.length + " flags (" + c.high + " high, " + c.medium + " medium, " + c.low + " low); verdict_candidate " + A.candidates[lane]);
For the three worked examples (node build-check.js server bookings-paste.txt,
node build-check.js data bookings-paste.txt, node build-check.js fix
wallet-paste.txt) the counts it reports are the ones in the facts below:
server: 6 file(s), 6 fun(s), 3 route(s), 3 transaction(s); 0 flags (0 high, 0 medium, 0 low); verdict_candidate ready
data: 6 file(s), 6 fun(s), 3 route(s), 3 transaction(s); 0 flags (0 high, 0 medium, 0 low); verdict_candidate ready
fix: 5 file(s), 7 fun(s), 2 route(s), 5 transaction(s); 4 flags (2 high, 2 medium, 0 low); verdict_candidate blocked
For all three its code and facts are exactly what the page sends. The same
paste gives different facts per lane, so build the file for the lane you will run (a
server file sent with task: "data" carries no data flags to answer).
Without the page’s check. A programmatic caller that does not run
ktorkit.js must build facts itself: it is required, and the platform only
checks that it is a string. A minimal string with the same keys works, and the model still reads
code:
{"lane":"server","build":{"kotlin":null,"ktor":null,"exposed":null,"artifacts":[],"gradle_pasted":false,"kotlin_files":0,"functions":0,"suspend_funs":0,"routes":0,"transactions":0,"module_pasted":false},"files":[],"functions":[],"routes":[],"plugins":[],"jwt":[],"transactions":[],"tables":[],"entities":[],"flags":[],"verdict_candidate":null,"notes":["No page check was run; read the code directly."]}
Set lane to the task you send. But fill in the fields the model needs, or you
lose what the check adds: with no flags there is nothing to confirm, dismiss or close,
with no files there are no paths and line ranges to name, with no routes the
model does not know which authenticate block a route sits in once its Route fun is mounted elsewhere,
with no transactions it does not know which thread each one runs on, and with no
functions nothing says what must survive a fix and stay suspend or not.
Build it with ktorkit.js as above when you can; it costs nothing.
The audit_handoff string
Only for task: "fix", and only for the code the audit was about with every audit edit
applied. The page builds both from the normalised audit reply res
(recon.js normalize, below) and the analysis A of the audited
paste, in recon.js fixHandoff:
const run = Recon.applyFindings(A, res.findings, Ktorkit); // every finding's edits, in order, each on top of the last
JSON.stringify({ lane: res.task, verdict: res.verdict, headline: res.headline,
findings: res.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, title: f.title, why: f.why, edits: f.edits.length })),
applied: /* edits that applied */, failed: /* edits that did not */ })
// run.A.text is the edited paste: it becomes code, and facts is recomputed from it for the fix lane
That is {"lane", "verdict", "headline", "findings": [{id, severity, file, line, title, why,
edits}], "applied", "failed"}, where lane is server or
data and each finding’s edits is the number of edits it
carried. applyFindings applies every finding’s edits with
Ktorkit.applyEdits, in the order listed: each before must occur exactly once
in its file as the earlier edits left it, or that edit is reported as failed and skipped — it is
never guessed. The handoff is a previous reply, not evidence: the model checks it against
code and fixes what is left. This script does the whole step — the edited paste,
its recomputed fix-lane facts and the handoff string, in the same shape as build-check.js
prints, so the samples read it the same way:
// handoff.js -- run: node handoff.js orders-paste.txt orders-reply.txt server > orders-fix-check.json (ktorkit.js, recon.js next to it)
const fs = require("fs");
const Ktorkit = require("./ktorkit.js");
const Recon = require("./recon.js");
const A = Ktorkit.analyze(fs.readFileSync(process.argv[2], "utf8")); // the audited paste
const lane = process.argv[4] || "server"; // the audit's lane: server or data
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);
const ho = Recon.fixHandoff(A, res); // every edit, in order
const B = Ktorkit.analyze(ho.text); // facts recomputed from the edited paste
const inp = Ktorkit.buildInput("fix", B, "", ho.handoff);
console.log(JSON.stringify({ task: "fix", code: inp.code, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.applied + " edit(s) applied, " + ho.failed + " did not apply");
Save the audit’s output.output as the reply file. An audit with no findings (like
the two bookings audits below) hands on no edit: its handoff carries "findings": []
and "applied": 0, and a fix run on it works from the paste and its facts alone. For the
server-to-data step,
take ho.text (the paste with every server edit applied) and run
build-check.js data on it: the data audit gets no handoff string.
The output contracts
The model is instructed (SKILL.md) to reply with one JSON object and
nothing else — no prose, no code fences — with every array of its lane
present ([] when empty). The run’s output text is that object. In every lane it
must ground everything in code: a caller, a plugin installed elsewhere, an
application.conf value, a migration, an index, a database constraint, a Koin module or a
library’s behaviour that is not in the paste is never claimed to exist — it goes into
questions (“if DatabaseFactory.create builds a HikariDataSource ...”, never
“it does”). It must never write a secret value: a line that holds a literal credential (a
password, a JWT signing key, a token, a JDBC URL with a password) is never copied into the reply (not
in evidence, not in an edit, not in a file), and no credential, real hostname or endpoint is invented
(example hosts use example). It must never change what the code offers its callers:
public classes, funs and properties keep their names, parameters, return types and whether they are
suspend, unless the note allows more. When the only real fix changes a public signature and
the note does not allow it, an audit keeps the finding with edits: [] and says in
why what to change, and the fix lane names it in open. Before using an API it
must be sure it exists in the Ktor and Exposed versions the paste uses (the build file, else the
imports), and it never adds a dependency the paste does not already have unless the note allows it.
Every flag of the lane whose sev is not info is answered exactly once (audit
lanes: in flag_answers), and an info flag is never reported as a finding.
task: "server" and task: "data"
task:"server"or"data", the lane that was asked for.verdict:blocked(any finding is high) |needs_work(the worst is medium) |ready(nothing above low; low findings and questions do not stop a release).headline: one sentence.system: one or two sentences — what the service does and what the audited part of it is.areas[]: exactly one entry per area of the lane, in the lane’s order — server:auth,handlers,errors,threads; data:transactions,queries,setup— as{area, status, note}withstatussound|fragile|broken(brokenwhen a high finding sits in that area,fragilefor a medium one).findings[]:{id, flags[], file, line, severity, area, title, evidence, why, edits[]}.idis a catalogue id of the lane (server: KT-01..KT-10 or KV-01; data: EX-01..EX-10 or KV-01) orother;flagslists the pageuids it confirms ([]for something the page did not flag);fileis apathfromfacts.files, copied exactly;lineis the line ofcodewith the offending text, inside that file’sline..end_line;severityishigh(the server stalls, data is destroyed or anyone can act as a user: KT-01, KT-03 withallowCredentials, EX-02, EX-09) |medium(requests fail, leak or lose work under normal use: KT-02, KT-03 without credentials, KT-04, KT-05, KT-06, KT-08, KT-09, EX-01, EX-03, EX-04, EX-05, EX-06 for a query or a transaction per item, EX-07, EX-08 when Flyway also runs, KV-01) |low(bounded risk or hygiene: KT-07, KT-10, EX-06 for inserts only, EX-08 without Flyway, EX-10);evidenceis one to three whole lines copied verbatim fromcodeas pasted (indentation may be trimmed). A problem of the other audit is named in one question (“run the data audit”), not reported as a finding.edits[]:{before, after}on that one file. Eachbeforeis a contiguous, exact slice of the file — whole lines with their indentation, as pasted — that occurs exactly once in the file as it stands when the edit is applied;afteris its replacement, with the same indentation (four spaces, never tabs). Findings are applied in the order listed and their edits in order, each on top of the ones before, so a later finding that touches the same lines writes itsbeforeas the earlier one left it. A new import is an edit of an existing import line, in the first finding that needs it. Every line an edit adds follows the house rules itself (norunBlocking, no!!on request data, a return after every early respond,newSuspendedTransactionin coroutine code, no values in SQL text), so the page’s re-check raises nothing new.editsmay be[]only for a low finding, a change outside the paste, or a change the signature rule forbids.flag_answers[]:{flag, status, reason}, exactly one per non-info flag of the lane,statusconfirmed(a finding lists that uid) |dismissed(with the reason).plan[]: the findings in the order to apply them — what stalls the server, destroys data or lets anyone in first, then what fails or leaks under normal use, then hygiene — ending with a test that would have caught the worst problem: server, atestApplication { }test (no token to a protected route gets 401, an unknown id 404 not 500, a bad id 400); data, a repository test against a real Postgres (two concurrent operations on the same row, one of which must fail or wait). With no finding, the plan is just those tests.questions[](only what the paste cannot answer) andsummary(two to four sentences, answeringnoteif one was sent).
task: "fix"
task:"fix".verdict:fixed(openis empty) |partial.headline: one sentence.files[]:{path, content}— every pasted file it changes, whole (every line, without the// file:line, indented as the file already is: four spaces per level, never tabs, no extra leading whitespace), under itspathfromfacts.files. At most one new file next to them (a migration.sqlnext to the pasted ones, a small helper).changes[]:{file, function, flags[], change, why}, one per change.functionis the fun it is in, asfacts.functionsnames it (Class.fun,Route.slotRoutes), or(module)for a change outside any fun (an import, a property, a table object) or(gradle)for the build file;flagsare the page flags the change closes ([]for a problem the page did not flag). Every high and medium flag appears in theflagsof a change or of anopenitem.verify[]: always the build (./gradlew build), plus the tests that would have caught the worst problems: atestApplication { }test per route fixed (no token gives 401, an unknown id 404, a bad id 400, the error body carries no exception text), and a repository test against a real Postgres for every transaction fixed (two concurrent transfers from one balance: the second must fail or wait; an update touches one row).open[]:{item, flags[], reason}for every high or medium problem it left unfixed — a decision for the engineer, code outside the paste, or a change the signature rule forbids — with each item’s risk as it stands in the returned files. Low flags left as they are go insummaryorquestions, not inopen.questions[]andsummary, as in the audits; with anaudit_handoff, it fixes what the audit left.
How the page reads a reply (recon.js parseResult and
normalize): it strips a code fence and parses from the first { to the
last }. A reply is read as the lane that was asked for; one naming the fix lane when an
audit was asked (or the other way round) is read as that lane only when it carries that lane’s
body (a files array for fix, a findings array for an audit), and one naming
the other audit is read as the audit asked for, with the mismatch counted as a disagreement. An unknown
verdict shows as needs_work or partial and counts as a
disagreement; an unknown severity becomes medium; ids like KT1
or ex 9 become KT-01 and EX-09; flags keeps only
F<n> values; findings with no file, evidence and title are dropped;
system may also arrive as feature or service, file
as path, a change’s function as func and its
change as what, and an open item as path;
questions is capped at 10, plan at 30 and verify at 20. An audit
reply carries 9 sections (verdict, headline, system, areas, findings, flag_answers, plan, questions,
summary) and a fix reply 8 (verdict, headline, files, changes, verify, open, questions, summary),
which is what the page counts when it recovers a partial reply.
Worked examples
One per lane, from the examples the page ships (the example buttons on the app
page; the full pastes are in /example.js, ids bookings and
wallet), built exactly as the page builds them:
Ktorkit.buildInput(lane, Ktorkit.analyze(text), note, null), with the real model reply
the page replays for each (/example-result.js; the bookings data audit
is the clinic’s second saved reply). The server and data examples are the same paste run
in two lanes. The pastes are shown whole; the request bodies cut the long code and
facts strings with a visible ..., and the replies are pretty-printed, with
... only where a long file content was cut (the files are then shown whole). Every value
shown is copied from the input or the reply. No paste holds a credential, and the services are
invented.
| Example | task | Page flags of the lane | verdict_candidate | Reply verdict | Idempotency-Key the page sends |
|---|---|---|---|---|---|
| Clinic bookings | "server" | none (KV-00 only) | ready | ready | routrel-audit:server:e7ea62e2-3b88:a1 |
| Clinic bookings | "data" | none (KV-00 only) | ready | ready | routrel-audit:data:1ad72fe8-3b84:a1 |
| Wallet | "fix" | 4 (2 high, 2 medium) plus KV-00 | blocked | partial | routrel-audit:fix:5820c181-391b:a1 |
Server audit: a clinic booking service with its routes behind authenticate, a JWT verifier with audience and issuer, StatusPages that leak nothing and CORS limited to one host - ready
Six files: build.gradle.kts (Kotlin 1.9.24, Ktor 2.3.12, Exposed 0.52.0, HikariCP and
Flyway), src/main/resources/db/migration/V1__slots.sql (a slots table with
UNIQUE (practitioner_id, starts_at)), and under src/main/kotlin/com/varnwell/clinic/
Application.kt, SlotRoutes.kt, SlotRepository.kt and
DatabaseFactory.kt. Application.module reads the JWT audience, issuer and
secret from environment.config, installs ContentNegotiation with json(), CORS
with allowHost("book.varnwell.example", schemes = listOf("https")), a
jwt("patient") provider whose verifier calls withAudience and
withIssuer, and StatusPages that answers IllegalArgumentException with 400 and
any other Throwable with a fixed “Internal server error” after logging it; then
routing { slotRoutes(slots) }. Route.slotRoutes has a public
GET /practitioners/{pid}/slots and, inside authenticate("patient"),
POST and DELETE /slots/{id}/booking, each parsing its parameters with
toIntOrNull() and ?: return@... call.respond(...) and taking the patient from
the JWT subject. SlotRepository runs every operation in
newSuspendedTransaction(Dispatchers.IO, db), and books with one conditional
update whose row count is checked. The page’s free check raises no server flag, only
the KV-00 context flag; its own reading of the verdict is ready. The engineer’s note:
“Release candidate for the clinic's booking API. Anything to fix before it goes out?”
The code string, whole: 9,144 characters, 223 lines ending in a newline.
Save it as bookings-paste.txt and run
node build-check.js server bookings-paste.txt > bookings-check.json:
// file: build.gradle.kts
plugins {
kotlin("jvm") version "1.9.24"
id("io.ktor.plugin") version "2.3.12"
kotlin("plugin.serialization") version "1.9.24"
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("io.ktor:ktor-server-cors-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
implementation("org.jetbrains.exposed:exposed-core:0.52.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.52.0")
implementation("org.postgresql:postgresql:42.7.3")
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("org.flywaydb:flyway-core:10.15.0")
implementation("org.flywaydb:flyway-database-postgresql:10.15.0")
implementation("ch.qos.logback:logback-classic:1.5.6")
}
// file: src/main/resources/db/migration/V1__slots.sql
CREATE TABLE slots (
id SERIAL PRIMARY KEY,
practitioner_id INTEGER NOT NULL,
starts_at BIGINT NOT NULL,
patient_id VARCHAR(64),
CONSTRAINT ux_slots_practitioner_start UNIQUE (practitioner_id, starts_at)
);
// file: src/main/kotlin/com/varnwell/clinic/Application.kt
package com.varnwell.clinic
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
@Serializable
data class ApiError(val error: String)
fun Application.module() {
val cfg = environment.config
val audience = cfg.property("jwt.audience").getString()
val issuer = cfg.property("jwt.issuer").getString()
val secret = cfg.property("jwt.secret").getString()
val slots = SlotRepository(DatabaseFactory.create(cfg))
install(ContentNegotiation) {
json()
}
install(CORS) {
allowHost("book.varnwell.example", schemes = listOf("https"))
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowMethod(HttpMethod.Delete)
}
install(Authentication) {
jwt("patient") {
realm = "varnwell"
verifier(
JWT.require(Algorithm.HMAC256(secret))
.withAudience(audience)
.withIssuer(issuer)
.build()
)
validate { credential ->
if (credential.payload.subject.isNullOrBlank()) null else JWTPrincipal(credential.payload)
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, ApiError("Sign in again"))
}
}
}
install(StatusPages) {
exception<IllegalArgumentException> { call, cause ->
call.respond(HttpStatusCode.BadRequest, ApiError(cause.message ?: "Bad request"))
}
exception<Throwable> { call, cause ->
call.application.environment.log.error("Unhandled error", cause)
call.respond(HttpStatusCode.InternalServerError, ApiError("Internal server error"))
}
}
routing {
slotRoutes(slots)
}
}
// file: src/main/kotlin/com/varnwell/clinic/SlotRoutes.kt
package com.varnwell.clinic
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
fun Route.slotRoutes(slots: SlotRepository) {
route("/practitioners/{pid}/slots") {
get {
val pid = call.parameters["pid"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, ApiError("pid must be a number"))
val from = call.request.queryParameters["from"]?.toLongOrNull() ?: 0L
call.respond(slots.freeSlots(pid, from, limit = 50))
}
}
authenticate("patient") {
route("/slots/{id}") {
post("/booking") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@post call.respond(HttpStatusCode.BadRequest, ApiError("id must be a number"))
val patient = call.principal<JWTPrincipal>()?.subject
?: return@post call.respond(HttpStatusCode.Unauthorized, ApiError("Sign in again"))
if (slots.book(id, patient)) call.respond(HttpStatusCode.Created, BookingResult(id, patient))
else call.respond(HttpStatusCode.Conflict, ApiError("This slot is no longer free"))
}
delete("/booking") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, ApiError("id must be a number"))
val patient = call.principal<JWTPrincipal>()?.subject
?: return@delete call.respond(HttpStatusCode.Unauthorized, ApiError("Sign in again"))
if (slots.cancel(id, patient)) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound, ApiError("No booking of yours on this slot"))
}
}
}
}
// file: src/main/kotlin/com/varnwell/clinic/SlotRepository.kt
package com.varnwell.clinic
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.update
object Slots : IntIdTable("slots") {
val practitionerId = integer("practitioner_id")
val startsAt = long("starts_at")
val patientId = varchar("patient_id", 64).nullable()
}
@Serializable
data class Slot(val id: Int, val practitionerId: Int, val startsAt: Long)
@Serializable
data class BookingResult(val slotId: Int, val patient: String)
class SlotRepository(private val db: Database) {
suspend fun freeSlots(practitionerId: Int, from: Long, limit: Int): List<Slot> = newSuspendedTransaction(Dispatchers.IO, db) {
Slots.selectAll()
.where { (Slots.practitionerId eq practitionerId) and (Slots.startsAt greaterEq from) and Slots.patientId.isNull() }
.orderBy(Slots.startsAt, SortOrder.ASC)
.limit(limit)
.map { Slot(it[Slots.id].value, it[Slots.practitionerId], it[Slots.startsAt]) }
}
/* One conditional UPDATE: it books the slot only while it is still free, so two patients racing for
* the same slot cannot both win - the second one updates no row. */
suspend fun book(slotId: Int, patient: String): Boolean = newSuspendedTransaction(Dispatchers.IO, db) {
Slots.update({ (Slots.id eq slotId) and Slots.patientId.isNull() }) {
it[patientId] = patient
} == 1
}
suspend fun cancel(slotId: Int, patient: String): Boolean = newSuspendedTransaction(Dispatchers.IO, db) {
Slots.update({ (Slots.id eq slotId) and (Slots.patientId eq patient) }) {
it[patientId] = null
} == 1
}
}
// file: src/main/kotlin/com/varnwell/clinic/DatabaseFactory.kt
package com.varnwell.clinic
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.config.ApplicationConfig
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database
object DatabaseFactory {
fun create(cfg: ApplicationConfig): Database {
val config = HikariConfig().apply {
jdbcUrl = cfg.property("database.url").getString()
username = cfg.property("database.user").getString()
password = cfg.property("database.password").getString()
maximumPoolSize = cfg.property("database.maxPoolSize").getString().toInt()
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validate()
}
val dataSource = HikariDataSource(config)
Flyway.configure().dataSource(dataSource).locations("classpath:db/migration").load().migrate()
return Database.connect(dataSource)
}
}
Its server-lane facts, decoded (the request carries this object as one compact JSON string):
{
"lane": "server",
"build": {
"kotlin": "1.9.24",
"ktor": "2.3.12",
"exposed": "0.52.0",
"artifacts": [
"ktor-server-core-jvm",
"ktor-server-netty-jvm",
"ktor-server-content-negotiation-jvm",
"ktor-serialization-kotlinx-json-jvm",
"ktor-server-auth-jvm",
"ktor-server-auth-jwt-jvm",
"ktor-server-cors-jvm",
"ktor-server-status-pages-jvm",
"exposed-core",
"exposed-jdbc",
"postgresql",
"HikariCP",
"flyway-core",
"flyway-database-postgresql"
],
"gradle_pasted": true,
"kotlin_files": 4,
"functions": 6,
"suspend_funs": 3,
"routes": 3,
"transactions": 3,
"module_pasted": true
},
"files": [
{
"path": "build.gradle.kts",
"kind": "gradle",
"line": 2,
"end_line": 24
},
{
"path": "src/main/resources/db/migration/V1__slots.sql",
"kind": "other",
"line": 26,
"end_line": 32
},
{
"path": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"kind": "kotlin",
"line": 34,
"end_line": 103
},
{
"path": "src/main/kotlin/com/varnwell/clinic/SlotRoutes.kt",
"kind": "kotlin",
"line": 105,
"end_line": 150
},
{
"path": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"kind": "kotlin",
"line": 152,
"end_line": 198
},
{
"path": "src/main/kotlin/com/varnwell/clinic/DatabaseFactory.kt",
"kind": "kotlin",
"line": 200,
"end_line": 224
}
],
"functions": [
{
"name": "Application.module",
"file": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"line": 58,
"suspend": false,
"private": false
},
{
"name": "Route.slotRoutes",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRoutes.kt",
"line": 119,
"suspend": false,
"private": false
},
{
"name": "SlotRepository.freeSlots",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"line": 177,
"suspend": true,
"private": false
},
{
"name": "SlotRepository.book",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"line": 187,
"suspend": true,
"private": false
},
{
"name": "SlotRepository.cancel",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"line": 193,
"suspend": true,
"private": false
},
{
"name": "DatabaseFactory.create",
"file": "src/main/kotlin/com/varnwell/clinic/DatabaseFactory.kt",
"line": 209,
"suspend": false,
"private": false
}
],
"routes": [
{
"method": "GET",
"path": "/practitioners/{pid}/slots",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRoutes.kt",
"line": 121,
"func": "Route.slotRoutes",
"auth": null,
"mounted": true,
"via": [
"Route.slotRoutes (mounted at src/main/kotlin/com/varnwell/clinic/Application.kt:101)"
]
},
{
"method": "POST",
"path": "/slots/{id}/booking",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRoutes.kt",
"line": 131,
"func": "Route.slotRoutes",
"auth": "patient",
"mounted": true,
"via": [
"Route.slotRoutes (mounted at src/main/kotlin/com/varnwell/clinic/Application.kt:101)"
]
},
{
"method": "DELETE",
"path": "/slots/{id}/booking",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRoutes.kt",
"line": 140,
"func": "Route.slotRoutes",
"auth": "patient",
"mounted": true,
"via": [
"Route.slotRoutes (mounted at src/main/kotlin/com/varnwell/clinic/Application.kt:101)"
]
}
],
"plugins": [
{
"name": "ContentNegotiation",
"file": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"line": 65,
"func": "Application.module"
},
{
"name": "CORS",
"file": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"line": 68,
"func": "Application.module",
"any_host": false,
"allow_credentials": false,
"hosts": [
"book.varnwell.example"
]
},
{
"name": "Authentication",
"file": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"line": 74,
"func": "Application.module"
},
{
"name": "StatusPages",
"file": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"line": 91,
"func": "Application.module"
}
],
"jwt": [
{
"name": "patient",
"file": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"line": 75,
"verifier": true,
"audience": true,
"issuer": true,
"validate": true
}
],
"transactions": [
{
"file": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"line": 177,
"func": "SlotRepository.freeSlots",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [],
"queries": [
"Slots.selectAll"
]
},
{
"file": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"line": 187,
"func": "SlotRepository.book",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [
"update"
],
"queries": []
},
{
"file": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"line": 193,
"func": "SlotRepository.cancel",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [
"update"
],
"queries": []
}
],
"tables": [
{
"name": "Slots",
"base": "IntIdTable",
"sql_name": "slots",
"file": "src/main/kotlin/com/varnwell/clinic/SlotRepository.kt",
"line": 164
}
],
"entities": [],
"flags": [
{
"uid": "F1",
"id": "KV-00",
"sev": "info",
"file": "src/main/kotlin/com/varnwell/clinic/Application.kt",
"line": 34,
"text": "context: 4 Kotlin files, 6 fun(s) (3 suspend), 3 route(s), 4 plugin install(s), 3 Exposed transaction(s), 1 table(s), 0 entities; Ktor 2.3.12; Exposed 0.52.0",
"detail": "Also read: src/main/resources/db/migration/V1__slots.sql. Only what is pasted is checked: routes in Route functions that are never mounted in the paste get their path and authenticate block from nowhere (their auth is not judged), what a fun in another file does is unknown, and the database's own indexes and constraints are outside the check unless the migrations are pasted."
}
],
"verdict_candidate": "ready",
"notes": []
}
The request body (POST /estimate, then POST /run or
/run-stream with Idempotency-Key: routrel-audit:server:e7ea62e2-3b88:a1 as
the page sends it):
{
"task": "server",
"code": "// file: build.gradle.kts\nplugins {\n kotlin(\"jvm\") version \"1.9.24\"\n id(\"io.ktor.plugin\") version \"2.3.12\"\n...",
"facts": "{\"lane\":\"server\",\"build\":{\"kotlin\":\"1.9.24\",\"ktor\":\"2.3.12\",\"exposed\":\"0.52.0\",\"artifacts\":[\"ktor-server-core-jvm\",\"ktor-server-netty-jvm\",\"ktor-server-content-negotiatio...",
"note": "Release candidate for the clinic's booking API. Anything to fix before it goes out?"
}
The reply (output.output), whole:
{
"task": "server",
"verdict": "ready",
"headline": "Auth, error handling and threading in this Ktor server are sound; nothing here blocks release.",
"system": "This is a Ktor + Exposed booking API for a clinic: patients browse a practitioner's free slots and book or cancel their own slot by id; the audited part is the Application module, its plugins, and the slot routes.",
"areas": [
{
"area": "auth",
"status": "sound",
"note": "The JWT verifier checks both audience and issuer, CORS is limited to book.varnwell.example with no allowCredentials, and both state-changing routes sit inside authenticate(\"patient\") with the booking/cancel identity taken only from the JWT subject, not from client input."
},
{
"area": "handlers",
"status": "sound",
"note": "Every path and query parameter is parsed with toIntOrNull/toLongOrNull and answered with an early return@... 400 or a default, call.principal<JWTPrincipal>() is guarded with ?: return, and each handler's if/else covers both respond branches with no fall-through."
},
{
"area": "errors",
"status": "sound",
"note": "StatusPages maps IllegalArgumentException to 400 with its message and every other Throwable to a fixed \"Internal server error\" after logging the cause, so no exception text or stack trace reaches the client."
},
{
"area": "threads",
"status": "sound",
"note": "All three repository operations run inside newSuspendedTransaction(Dispatchers.IO, db) from suspend functions; no runBlocking or Thread.sleep appears in a route handler."
}
],
"findings": [],
"flag_answers": [],
"plan": [
"Add a testApplication test that calls POST and DELETE /slots/{id}/booking with no Authorization header and asserts 401.",
"Add a testApplication test that calls POST /slots/{id}/booking with a non-numeric id (e.g. /slots/abc/booking) and asserts 400.",
"Add a testApplication test that calls POST /slots/{id}/booking for a slot that does not exist or is already booked and asserts 409, and DELETE /slots/{id}/booking for a slot the caller never booked and asserts 404."
],
"questions": [],
"summary": "Nothing in the server layer needs a fix before release: JWT verification, CORS, StatusPages and every route's parameter and principal handling follow the house rules, and no handler blocks a thread. The only outstanding gap is test coverage - add the testApplication cases in the plan to lock in the 401/400/409/404 behavior before shipping."
}
With no server flag to answer, findings and flag_answers are both
[], which is what the contract asks for: an empty flag_answers is right only
when the lane’s facts carry no non-info flag. The four areas come back in the lane’s order,
all sound, so the verdict is ready, and with no finding the plan is just the
testApplication tests. The data layer of the same paste is the next example.
Data audit: the same clinic paste, run in the data lane - suspended transactions on a Hikari pool, Flyway migrations and a conditional UPDATE that books a slot once
The code string is the same 9,144-character paste as the server example (the page’s
example stores a second saved reply for this lane). The data lane’s facts are the
server facts above with one key changed: "lane": "data". Every other key
(build, files, functions, routes,
plugins, jwt, transactions, tables,
entities, the lone KV-00 flag F1, verdict_candidate: "ready"
and notes) is identical, because the paste raises neither a server nor a data flag. Build
it with node build-check.js data bookings-paste.txt > bookings-data-check.json. The
three transactions it lists are the ones this lane reviews: SlotRepository.freeSlots
(queries: ["Slots.selectAll"]), SlotRepository.book and
SlotRepository.cancel (writes: ["update"]), each a
newSuspendedTransaction with coroutine: true and dispatcher: "io".
The request body (Idempotency-Key: routrel-audit:data:1ad72fe8-3b84:a1):
{
"task": "data",
"code": "// file: build.gradle.kts\nplugins {\n kotlin(\"jvm\") version \"1.9.24\"\n id(\"io.ktor.plugin\") version \"2.3.12\"\n...",
"facts": "{\"lane\":\"data\",\"build\":{\"kotlin\":\"1.9.24\",\"ktor\":\"2.3.12\",\"exposed\":\"0.52.0\",\"artifacts\":[\"ktor-server-core-jvm\",\"ktor-server-netty-jvm\",\"ktor-server-content-negotiation-...",
"note": "Release candidate for the clinic's booking API. Anything to fix before it goes out?"
}
The reply (output.output), whole, pretty-printed (the model sent it on one line):
{
"task": "data",
"verdict": "ready",
"headline": "The Exposed data layer books and cancels slots with single atomic conditional updates and a pooled, migrated setup, with nothing left to fix before release.",
"system": "This Ktor service lets patients list a practitioner's free slots and book or cancel a booking on one; the audited part is the Exposed data layer - the Slots table, SlotRepository's transactions and queries, and DatabaseFactory's connection and migration setup.",
"areas": [
{
"area": "transactions",
"status": "sound",
"note": "freeSlots, book and cancel each run one newSuspendedTransaction(Dispatchers.IO, db), and book/cancel decide and write in a single conditional update whose row count is checked, so no operation is split across transactions or races on the same row."
},
{
"area": "queries",
"status": "sound",
"note": "No query builds SQL from values or a LIKE pattern, no query or update runs inside a loop, freeSlots takes a fixed limit and maps rows to a plain Slot inside its transaction, and neither update can find nothing since its row count is checked instead of calling single() or first()."
},
{
"area": "setup",
"status": "sound",
"note": "DatabaseFactory builds a HikariDataSource with a pool size, isAutoCommit = false and an isolation level, and a versioned Flyway migration owns the schema; SchemaUtils.create is not used."
}
],
"findings": [],
"flag_answers": [],
"plan": [
"Add a repository test against a real Postgres (Testcontainers) that fires two concurrent SlotRepository.book calls for the same slot id and asserts exactly one returns true and the other false.",
"Add a repository test asserting SlotRepository.freeSlots for one practitioner runs in one query and returns no more than its limit."
],
"questions": [],
"summary": "Booking and cancelling each run inside one newSuspendedTransaction and decide-and-write in a single conditional UPDATE whose affected-row count is checked, so two patients racing for the same slot cannot both win. Queries build no SQL from values, use no LIKE pattern or loops, and DatabaseFactory pools connections through HikariCP behind a versioned Flyway migration. This part of the code has nothing to fix before release; add the two concurrency and query-shape tests in plan first."
}
Three areas, in the data lane’s order (transactions, queries,
setup), all sound. The two plan steps are the repository tests the contract
asks for when there is no finding: two concurrent book calls on one slot, exactly one of
which wins, and a query-shape test for freeSlots.
Fix: a wallet data layer whose setBalance updates every row, whose statement query splices the owner into SQL and whose transfer runs in five transactions - with findByOwner’s entity return type kept on purpose
Five files: build.gradle.kts (Ktor 2.3.12, Exposed 0.52.0 core, DAO and JDBC, the Postgres
driver) and under src/main/kotlin/com/quillbank/wallet/ Tables.kt (a
Wallets UUIDTable, a Transfers LongIdTable and a
WalletEntity), WalletRepository.kt, TransferService.kt and
WalletRoutes.kt. Every repository fun opens its own
newSuspendedTransaction(Dispatchers.IO, db): findByOwner returns the
WalletEntity? it loaded, setBalance calls Wallets.update { } with
no where, and statement builds its exec(...) SQL with
'$owner' and '$month' templates. TransferService.transfer reads
both balances, checks the source, and calls setBalance twice and
recordTransfer once — five transactions for one transfer. Route.walletRoutes
sits inside authenticate("customer") but is never mounted in the paste (no Application
module was pasted), so its routes carry mounted: false. The page’s free check
raises 4 flags for the fix lane (2 high, 2 medium) plus KV-00; the verdict its flags point at is
blocked. The engineer’s note: “Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.”
The code string, whole: 6,041 characters, 151 lines ending in a newline.
Save it as wallet-paste.txt and run
node build-check.js fix wallet-paste.txt > wallet-check.json:
// file: build.gradle.kts
plugins {
kotlin("jvm") version "1.9.24"
id("io.ktor.plugin") version "2.3.12"
kotlin("plugin.serialization") version "1.9.24"
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("org.jetbrains.exposed:exposed-core:0.52.0")
implementation("org.jetbrains.exposed:exposed-dao:0.52.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.52.0")
implementation("org.postgresql:postgresql:42.7.3")
}
// file: src/main/kotlin/com/quillbank/wallet/Tables.kt
package com.quillbank.wallet
import org.jetbrains.exposed.dao.UUIDEntity
import org.jetbrains.exposed.dao.UUIDEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.dao.id.UUIDTable
import java.util.UUID
object Wallets : UUIDTable("wallets") {
val owner = varchar("owner", 64).uniqueIndex()
val balanceCents = long("balance_cents")
val currency = varchar("currency", 3)
}
object Transfers : LongIdTable("transfers") {
val fromWallet = reference("from_wallet", Wallets)
val toWallet = reference("to_wallet", Wallets)
val amountCents = long("amount_cents")
val createdAt = long("created_at")
}
class WalletEntity(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<WalletEntity>(Wallets)
var owner by Wallets.owner
var balanceCents by Wallets.balanceCents
var currency by Wallets.currency
}
// file: src/main/kotlin/com/quillbank/wallet/WalletRepository.kt
package com.quillbank.wallet
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.update
import java.util.UUID
class WalletRepository(private val db: Database) {
suspend fun findByOwner(owner: String): WalletEntity? = newSuspendedTransaction(Dispatchers.IO, db) {
WalletEntity.find { Wallets.owner eq owner }.firstOrNull()
}
suspend fun balance(id: UUID): Long? = newSuspendedTransaction(Dispatchers.IO, db) {
WalletEntity.findById(id)?.balanceCents
}
suspend fun setBalance(id: UUID, cents: Long) {
newSuspendedTransaction(Dispatchers.IO, db) {
Wallets.update {
it[balanceCents] = cents
}
}
}
suspend fun recordTransfer(from: UUID, to: UUID, cents: Long) {
newSuspendedTransaction(Dispatchers.IO, db) {
Transfers.insert {
it[fromWallet] = from
it[toWallet] = to
it[amountCents] = cents
it[createdAt] = System.currentTimeMillis()
}
}
}
suspend fun statement(owner: String, month: String): List<String> = newSuspendedTransaction(Dispatchers.IO, db) {
val lines = mutableListOf<String>()
exec(
"SELECT t.amount_cents, t.created_at FROM transfers t JOIN wallets w ON w.id = t.from_wallet " +
"WHERE w.owner = '$owner' AND to_char(to_timestamp(t.created_at / 1000), 'YYYY-MM') = '$month' ORDER BY t.created_at"
) { rs ->
while (rs.next()) lines += "${rs.getLong(2)}: -${rs.getLong(1)}"
}
lines
}
}
// file: src/main/kotlin/com/quillbank/wallet/TransferService.kt
package com.quillbank.wallet
import java.util.UUID
class InsufficientFunds(message: String) : IllegalArgumentException(message)
class TransferService(private val repo: WalletRepository) {
suspend fun transfer(from: UUID, to: UUID, cents: Long) {
require(cents > 0) { "amount must be positive" }
require(from != to) { "cannot transfer to the same wallet" }
val fromBalance = repo.balance(from) ?: throw IllegalArgumentException("no wallet $from")
val toBalance = repo.balance(to) ?: throw IllegalArgumentException("no wallet $to")
if (fromBalance < cents) throw InsufficientFunds("balance too low")
repo.setBalance(from, fromBalance - cents)
repo.setBalance(to, toBalance + cents)
repo.recordTransfer(from, to, cents)
}
}
// file: src/main/kotlin/com/quillbank/wallet/WalletRoutes.kt
package com.quillbank.wallet
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import java.util.UUID
fun Route.walletRoutes(repo: WalletRepository, transfers: TransferService) {
authenticate("customer") {
get("/wallet/statement") {
val owner = call.principal<JWTPrincipal>()?.subject
?: return@get call.respond(HttpStatusCode.Unauthorized)
val month = call.request.queryParameters["month"]
?: return@get call.respond(HttpStatusCode.BadRequest, "month is required, as YYYY-MM")
call.respond(repo.statement(owner, month))
}
post("/wallet/transfers") {
val owner = call.principal<JWTPrincipal>()?.subject
?: return@post call.respond(HttpStatusCode.Unauthorized)
val wallet = repo.findByOwner(owner) ?: return@post call.respond(HttpStatusCode.NotFound)
val to = call.request.queryParameters["to"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
?: return@post call.respond(HttpStatusCode.BadRequest, "to must be a wallet id")
val cents = call.request.queryParameters["cents"]?.toLongOrNull()
?: return@post call.respond(HttpStatusCode.BadRequest, "cents must be a number")
transfers.transfer(wallet.id.value, to, cents)
call.respond(HttpStatusCode.NoContent)
}
}
}
Its fix-lane facts, decoded:
{
"lane": "fix",
"build": {
"kotlin": "1.9.24",
"ktor": "2.3.12",
"exposed": "0.52.0",
"artifacts": [
"ktor-server-core-jvm",
"ktor-server-auth-jvm",
"ktor-server-auth-jwt-jvm",
"exposed-core",
"exposed-dao",
"exposed-jdbc",
"postgresql"
],
"gradle_pasted": true,
"kotlin_files": 4,
"functions": 7,
"suspend_funs": 6,
"routes": 2,
"transactions": 5,
"module_pasted": false
},
"files": [
{
"path": "build.gradle.kts",
"kind": "gradle",
"line": 2,
"end_line": 16
},
{
"path": "src/main/kotlin/com/quillbank/wallet/Tables.kt",
"kind": "kotlin",
"line": 18,
"end_line": 46
},
{
"path": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"kind": "kotlin",
"line": 48,
"end_line": 95
},
{
"path": "src/main/kotlin/com/quillbank/wallet/TransferService.kt",
"kind": "kotlin",
"line": 97,
"end_line": 114
},
{
"path": "src/main/kotlin/com/quillbank/wallet/WalletRoutes.kt",
"kind": "kotlin",
"line": 116,
"end_line": 152
}
],
"functions": [
{
"name": "WalletRepository.findByOwner",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 58,
"suspend": true,
"private": false
},
{
"name": "WalletRepository.balance",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 62,
"suspend": true,
"private": false
},
{
"name": "WalletRepository.setBalance",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 66,
"suspend": true,
"private": false
},
{
"name": "WalletRepository.recordTransfer",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 74,
"suspend": true,
"private": false
},
{
"name": "WalletRepository.statement",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 85,
"suspend": true,
"private": false
},
{
"name": "TransferService.transfer",
"file": "src/main/kotlin/com/quillbank/wallet/TransferService.kt",
"line": 104,
"suspend": true,
"private": false
},
{
"name": "Route.walletRoutes",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRoutes.kt",
"line": 129,
"suspend": false,
"private": false
}
],
"routes": [
{
"method": "GET",
"path": "/wallet/statement",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRoutes.kt",
"line": 131,
"func": "Route.walletRoutes",
"auth": "customer",
"mounted": false
},
{
"method": "POST",
"path": "/wallet/transfers",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRoutes.kt",
"line": 139,
"func": "Route.walletRoutes",
"auth": "customer",
"mounted": false
}
],
"plugins": [],
"jwt": [],
"transactions": [
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 58,
"func": "WalletRepository.findByOwner",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [],
"queries": [
"WalletEntity.find"
]
},
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 62,
"func": "WalletRepository.balance",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [],
"queries": [
"WalletEntity.findById"
]
},
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 67,
"func": "WalletRepository.setBalance",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [
"update"
],
"queries": []
},
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 75,
"func": "WalletRepository.recordTransfer",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [
"insert"
],
"queries": []
},
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 85,
"func": "WalletRepository.statement",
"kind": "newSuspendedTransaction",
"blocking": false,
"coroutine": true,
"dispatcher": "io",
"writes": [],
"queries": []
}
],
"tables": [
{
"name": "Wallets",
"base": "UUIDTable",
"sql_name": "wallets",
"file": "src/main/kotlin/com/quillbank/wallet/Tables.kt",
"line": 27
},
{
"name": "Transfers",
"base": "LongIdTable",
"sql_name": "transfers",
"file": "src/main/kotlin/com/quillbank/wallet/Tables.kt",
"line": 33
}
],
"entities": [
{
"name": "WalletEntity",
"base": "UUIDEntity",
"file": "src/main/kotlin/com/quillbank/wallet/Tables.kt",
"line": 40
}
],
"flags": [
{
"uid": "F1",
"id": "EX-09",
"sev": "high",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 68,
"text": "the newSuspendedTransaction { } in WalletRepository.setBalance calls Wallets.update { } with no where condition, which changes every row of the table",
"detail": "Table.update's where is optional, and without it Exposed writes UPDATE ... SET with no WHERE: every row of Wallets gets these values at once. Pass the condition first: Wallets.update({ Wallets.id eq id }) { ... } (or .update(where = { ... })), and check the returned row count."
},
{
"uid": "F2",
"id": "EX-02",
"sev": "high",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 87,
"text": "the newSuspendedTransaction { } in WalletRepository.statement builds the SQL for exec(...) from a string template ($owner and $month)",
"detail": "Whatever is spliced into the SQL text is executed as SQL: a value such as ' OR '1'='1 or '; DROP TABLE ... turns into part of the statement (SQL injection), and even trusted values break on a quote. Build the query with Exposed's DSL (Table.selectAll().where { col eq value }), or keep raw SQL and pass the values as arguments - exec(sql, listOf(column.columnType to value)) in Exposed - never inside the text."
},
{
"uid": "F3",
"id": "EX-05",
"sev": "medium",
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"line": 58,
"text": "WalletRepository.findByOwner returns WalletEntity? - a DAO entity - out of the transaction that loaded it",
"detail": "An Exposed entity stays tied to the transaction that read it: reading a reference (referencedOn, referrersOn) after the transaction closed throws IllegalStateException (no transaction in context), and a property changed after it closed is not written by that transaction. Callers that get the entity cannot tell which of its members are safe to touch. Map the entity to a plain data class inside the transaction (toModel()) and return that."
},
{
"uid": "F4",
"id": "EX-04",
"sev": "medium",
"file": "src/main/kotlin/com/quillbank/wallet/TransferService.kt",
"line": 107,
"text": "TransferService.transfer calls balance(), setBalance() and recordTransfer(), and each opens its own transaction",
"detail": "Every call commits on its own, so TransferService.transfer is not atomic: a failure between the calls leaves the first one's write in place, and a concurrent request can act between them on what the first one read. Move the whole operation into one repository fun whose single newSuspendedTransaction { } does every read and write (repository funs that each open their own newSuspendedTransaction start separate transactions), and lock what it reads - forUpdate(), or a conditional UPDATE whose row count is checked - so a concurrent request cannot act in between.",
"lines": [
107,
108,
110,
111,
112
]
},
{
"uid": "F5",
"id": "KV-00",
"sev": "info",
"file": "src/main/kotlin/com/quillbank/wallet/Tables.kt",
"line": 18,
"text": "context: 4 Kotlin files, 7 fun(s) (6 suspend), 2 route(s) (2 in Route functions not mounted in the paste), 0 plugin install(s), 5 Exposed transaction(s), 2 table(s), 1 entity; Ktor 2.3.12; Exposed 0.52.0",
"detail": "No Application module with install(...) calls was pasted, so the missing-plugin checks (KT-09, KT-10) did not run. Only what is pasted is checked: routes in Route functions that are never mounted in the paste get their path and authenticate block from nowhere (their auth is not judged), what a fun in another file does is unknown, and the database's own indexes and constraints are outside the check unless the migrations are pasted."
}
],
"verdict_candidate": "blocked",
"notes": []
}
The request body (Idempotency-Key: routrel-audit:fix:5820c181-391b:a1):
{
"task": "fix",
"code": "// file: build.gradle.kts\nplugins {\n kotlin(\"jvm\") version \"1.9.24\"\n id(\"io.ktor.plugin\") version \"2.3.12\"\n...",
"facts": "{\"lane\":\"fix\",\"build\":{\"kotlin\":\"1.9.24\",\"ktor\":\"2.3.12\",\"exposed\":\"0.52.0\",\"artifacts\":[\"ktor-server-core-jvm\",\"ktor-server-auth-jvm\",\"ktor-server-auth-jwt-jvm\",\"exposed...",
"note": "Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release."
}
The reply (output.output), pretty-printed; each file’s content is cut
after its first lines here and shown whole below:
{
"task": "fix",
"verdict": "partial",
"headline": "Fixed the where-less update that wiped every wallet, parameterized the injectable statement query, and made transfers atomic; findByOwner's signature stays as the note requires, so its transaction-escaping entity remains open.",
"files": [
{
"path": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"content": "package com.quillbank.wallet\n\nimport kotlinx.coroutines.Dispatchers\n... (3,526 characters, shown whole below)"
},
{
"path": "src/main/kotlin/com/quillbank/wallet/TransferService.kt",
"content": "package com.quillbank.wallet\n\nimport java.util.UUID\n... (433 characters, shown whole below)"
}
],
"changes": [
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"function": "WalletRepository.setBalance",
"flags": [
"F1"
],
"change": "Added a where clause (Wallets.id eq id) to the update, so it only touches the targeted wallet.",
"why": "Prevents a single balance write from silently setting every wallet in the table to the same balance."
},
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"function": "WalletRepository.statement",
"flags": [
"F2"
],
"change": "Replaced the string-templated owner and month values with ? placeholders and passed them as exec arguments.",
"why": "Stops a wallet owner id or month value from being interpreted as part of the SQL statement."
},
{
"file": "src/main/kotlin/com/quillbank/wallet/WalletRepository.kt",
"function": "WalletRepository.transferBalance",
"flags": [],
"change": "Added a repository function that locks both wallets in a fixed id order, debits the source with a conditional update whose row count is checked, credits the destination, and inserts the transfer record, all inside one newSuspendedTransaction.",
"why": "Gives the whole transfer a single atomic, row-locked unit so a concurrent transfer cannot act on a balance the first transfer is still writing, and two transfers between the same two wallets cannot deadlock."
},
{
"file": "src/main/kotlin/com/quillbank/wallet/TransferService.kt",
"function": "TransferService.transfer",
"flags": [
"F4"
],
"change": "Replaced the separate balance(), balance(), setBalance(), setBalance() and recordTransfer() calls with one call to WalletRepository.transferBalance.",
"why": "The old code committed each read and write as its own transaction, so a concurrent transfer or a mid-way failure could leave both wallets' balances wrong; one transaction makes the debit, credit and record all-or-nothing."
}
],
"verify": [
"./gradlew build",
"A repository test against a real Postgres (Testcontainers): create two wallets, run transferBalance concurrently twice from the same source wallet with just enough balance for one transfer - exactly one call must succeed and the other must throw InsufficientFunds, and the source wallet's final balance must reflect only the one debit.",
"A repository test that calls setBalance on one wallet among several and asserts only that wallet's row changed.",
"A repository test that calls statement() with an owner or month value containing a quote (e.g. \"' OR '1'='1\") and asserts it is treated as a literal value, not as part of the query."
],
"open": [
{
"item": "WalletRepository.findByOwner still returns the WalletEntity itself after its transaction has closed.",
"flags": [
"F3"
],
"reason": "The note requires this function's signature to stay exactly as it is because the mobile backend calls it and reads the returned WalletEntity; mapping it to a plain data class (toModel()) would change that return type, so the fix is left out this release. Callers must only read owner, balanceCents and currency - properties already loaded before the transaction closed - and must never touch a lazy relation after the call returns."
}
],
"questions": [
"Does the mobile backend read anything from the WalletEntity that findByOwner returns besides owner, balanceCents and currency, or read it after the call has returned rather than immediately? Any such access throws IllegalStateException once the transaction is closed."
],
"summary": "Fixed the two high findings: setBalance's where-less update, which is what wiped every wallet to the same balance, and the string-templated SQL in statement(), which let an owner id or month value run as part of the query. Added WalletRepository.transferBalance so a transfer's balance check, debit, credit and record happen as one atomic, row-locked operation instead of three separate transactions, which is what let concurrent transfers leave balances wrong; TransferService.transfer now calls it and keeps its own signature and exceptions unchanged. findByOwner's signature is left exactly as it was per the note, so the DAO entity it returns still outlives its transaction - that risk is listed in open with a note on what is safe to read from it."
}
The returned src/main/kotlin/com/quillbank/wallet/WalletRepository.kt, whole (3,526 characters):
package com.quillbank.wallet
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.VarCharColumnType
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.update
import java.util.UUID
class WalletRepository(private val db: Database) {
suspend fun findByOwner(owner: String): WalletEntity? = newSuspendedTransaction(Dispatchers.IO, db) {
WalletEntity.find { Wallets.owner eq owner }.firstOrNull()
}
suspend fun balance(id: UUID): Long? = newSuspendedTransaction(Dispatchers.IO, db) {
WalletEntity.findById(id)?.balanceCents
}
suspend fun setBalance(id: UUID, cents: Long) {
newSuspendedTransaction(Dispatchers.IO, db) {
Wallets.update({ Wallets.id eq id }) {
it[balanceCents] = cents
}
}
}
suspend fun recordTransfer(from: UUID, to: UUID, cents: Long) {
newSuspendedTransaction(Dispatchers.IO, db) {
Transfers.insert {
it[fromWallet] = from
it[toWallet] = to
it[amountCents] = cents
it[createdAt] = System.currentTimeMillis()
}
}
}
suspend fun transferBalance(from: UUID, to: UUID, cents: Long) {
newSuspendedTransaction(Dispatchers.IO, db) {
val firstId = if (from.toString() <= to.toString()) from else to
val secondId = if (firstId == from) to else from
val firstRow = Wallets.selectAll().where { Wallets.id eq firstId }.forUpdate().singleOrNull()
val secondRow = Wallets.selectAll().where { Wallets.id eq secondId }.forUpdate().singleOrNull()
val rows = mapOf(firstId to firstRow, secondId to secondRow)
val fromRow = rows[from] ?: throw IllegalArgumentException("no wallet $from")
val toRow = rows[to] ?: throw IllegalArgumentException("no wallet $to")
val fromBalance = fromRow[Wallets.balanceCents]
val toBalance = toRow[Wallets.balanceCents]
if (fromBalance < cents) throw InsufficientFunds("balance too low")
val debited = Wallets.update({ (Wallets.id eq from) and (Wallets.balanceCents greaterEq cents) }) {
it[balanceCents] = fromBalance - cents
}
if (debited != 1) throw InsufficientFunds("balance too low")
Wallets.update({ Wallets.id eq to }) {
it[balanceCents] = toBalance + cents
}
Transfers.insert {
it[fromWallet] = from
it[toWallet] = to
it[amountCents] = cents
it[createdAt] = System.currentTimeMillis()
}
}
}
suspend fun statement(owner: String, month: String): List<String> = newSuspendedTransaction(Dispatchers.IO, db) {
val lines = mutableListOf<String>()
exec(
"SELECT t.amount_cents, t.created_at FROM transfers t JOIN wallets w ON w.id = t.from_wallet " +
"WHERE w.owner = ? AND to_char(to_timestamp(t.created_at / 1000), 'YYYY-MM') = ? ORDER BY t.created_at",
listOf(Wallets.owner.columnType to owner, VarCharColumnType() to month)
) { rs ->
while (rs.next()) lines += "${rs.getLong(2)}: -${rs.getLong(1)}"
}
lines
}
}
The returned src/main/kotlin/com/quillbank/wallet/TransferService.kt, whole (433 characters):
package com.quillbank.wallet
import java.util.UUID
class InsufficientFunds(message: String) : IllegalArgumentException(message)
class TransferService(private val repo: WalletRepository) {
suspend fun transfer(from: UUID, to: UUID, cents: Long) {
require(cents > 0) { "amount must be positive" }
require(from != to) { "cannot transfer to the same wallet" }
repo.transferBalance(from, to, cents)
}
}
The verdict is partial because the note asked to keep
WalletRepository.findByOwner’s signature this release, so the medium F3 (EX-05, the
entity returned out of its transaction) is named in open rather than fixed. The other
three flags are closed by changes: F1 by the where clause in setBalance, F2 by
? placeholders with the values passed as exec arguments in
statement, and F4 by TransferService.transfer calling the new
WalletRepository.transferBalance (itself listed as a change with flags: []),
which does the reads, the conditional debit, the credit and the insert in one
newSuspendedTransaction, locking both rows with forUpdate() in a fixed order.
Every fun of the paste is still there with its suspend kept (balance,
setBalance and recordTransfer are unchanged or only given a where), the new
fun is an addition, and Tables.kt, WalletRoutes.kt and the build file are not
returned because they did not change.
The page re-checks the reply against the code; the API does not
What comes back from the API is the model’s raw text in output.output. The page
holds it to account in the browser before showing it (recon.js
reconcile, with the facts, the code and the handoff it sent): it applies every edit or
returned file to the paste and re-runs its own free check on the result. None of that happens in the
API. A direct API caller gets the reply unchecked and must do the applying and re-checking itself.
- All lanes: the reply is the lane you asked for (one written for another task is a disagreement), it carries a valid verdict for that lane, and no credential from the paste is repeated anywhere in it (every credential value the free check found in the paste).
- Audits: the areas table. One row per area of the lane (auth, handlers, errors, threads; or transactions, queries, setup), each with a valid status.
- Audits: every finding is anchored. Its
idis in the lane’s catalogue (KT-01..KT-10 or EX-01..EX-10, KV-01, orother; KV-00 is context and never a finding); itsareabelongs to the lane; itsfileis a pasted file; itslineis inside that file; itsevidenceis found line by line in that file ofcode(indentation trimmed) and on or nearline; every citeduidis a non-info flag the page raised for this lane. A finding that carries a high flag at a lower severity, or a medium or high finding with no edit, is pointed out. - Audits: every flag is answered once, consistently. Every non-info flag of the lane answered exactly once and no other; a
confirmedanswer needs a finding carrying that uid; adismissedone must not be carried by a finding. The verdict must fit the worst severity (blockedwith a high finding,needs_workwith a medium, otherwiseready). - Audits: every edit is put in place and re-checked. The findings’ edits are applied in the listed order, each on top of the ones before (
Ktorkit.applyEdits): abeforethat is not found or is found more than once is a disagreement. After each finding the free check runs again: the code must still read and no new high flag may appear. With every edit applied, every confirmed flag a finding with edits carries must be gone, every fun of the paste must still exist, none may change betweenfunandsuspend fun, and a new medium flag is pointed out. - Fix: the files. At least one file, at most one new one; none empty, none with a
// file:line, every path a Kotlin (.kt,.kts), Gradle (.versions.toml) or SQL migration (.sql) file; a Kotlin file indented with tabs is pointed out, and one sent back with every line indented is a disagreement. - Fix: put into the paste and re-checked. The files replace the pasted files of the same path (or are added) (
Ktorkit.replaceFiles) and the free check runs again: the code must read, no high or medium flag of the paste may remain unless anopenitem names it, no new high or medium flag may appear, every fun must still exist, and none may change betweenfunandsuspend fun. - Fix: the changes list matches the code. Every change names a returned file, a fun in the fixed code (or
(module),(gradle)or(top level)) and real page flags; a high or medium flag no change and no open item names is pointed out. The verdict must fit theopenlist, and an emptyverify, or one with no test in it, is pointed out.
recon.js also runs in Node, so you can hold a reply to exactly the page’s standard.
Save /ktorkit.js and /recon.js next to this script,
pass the file you built the body from (build-check.js or handoff.js output,
which carries the lane as task) and the reply text; it prints every statement, the count,
and the paste with every edit (or file) put back:
// recheck.js -- run: node recheck.js wallet-check.json wallet-reply.txt > wallet-fixed.txt (ktorkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js"); // loads ./ktorkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); // task, code, facts (and audit_handoff) you sent
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), sent.task); // output.output
const r = Recon.reconcile(res, { facts: sent.facts, code: sent.code, handoff: sent.audit_handoff || null });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) (r.after.flags || []).forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " " + f.file + ":" + f.line + " " + f.text));
if (r.after && r.after.text) console.log(r.after.text.replace(/\n$/, "")); // the paste with every edit (or file) put back
Each statement is printed as ok:, warn:, bad: (a
disagreement) or info:. For the wallet fix reply, the one thing the reply itself says is
left is F3: findByOwner keeps returning the WalletEntity, as the note asked,
so expect it to be reported as left open by the reply rather than as a disagreement.
The page does not compile or run the code. After that, run the build (./gradlew build)
and the tests the reply’s plan or verify names: testApplication
{ } tests for every route (no token gives 401, an unknown id 404, a bad id 400, no exception text
in the error body) and repository tests against a real Postgres (Testcontainers, or a disposable
database in CI) — two concurrent operations on the same row, one of which must fail or wait;
an update that touches exactly one row — before you ship anything.
Step 1 — a tiny client helper
One function per language that does what sdk.js does for every call: JSON in,
bearer token if you have one (a personal token from /tokens.html, step 2), data out on a 2xx, an error carrying
status, error.code and error.message otherwise. The later
steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside
main / the top-level statements; Java’s makeInput from step 4 goes
in the class).
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # step 2
# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
method="$1"; path="$2"; shift 2
curl -sS -X "$method" "$BASE$path" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data' failure: jq '.error | {code, message}'
import hashlib, json, os, time, urllib.parse
import requests # pip install requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def api(method, path, body=None, headers=None):
h = {"Content-Type": "application/json"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
h.update(headers or {})
r = requests.request(method, BASE + path, headers=h,
data=None if body is None else json.dumps(body))
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason, err.get("details"))
return env.get("data")
// routrel-audit.mjs -- Node 18+ (global fetch), run with: node routrel-audit.mjs
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // step 2
async function api(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (TOKEN) h.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // step 2
type apiError struct {
Status int
Code string
Message string
}
func (e *apiError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func newRequest(method, path string, body any, headers map[string]string) (*http.Request, error) {
var buf []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
buf = b
}
req, err := http.NewRequest(method, base+path, bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func errorFrom(res *http.Response, env envelope) error {
e := &apiError{Status: res.StatusCode, Message: http.StatusText(res.StatusCode)}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return e
}
// api sends body as JSON and decodes the envelope's data into out (out may be nil).
func api(method, path string, body any, headers map[string]string, out any) error {
req, err := newRequest(method, path, body, headers)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
return errorFrom(res, env)
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
// steps 2-6 go here; remove any import a step you skip no longer uses
_, _, _, _, _ = bufio.NewScanner, sha256.Sum256, hex.EncodeToString, url.PathEscape, strings.Index
_ = time.Second
}
// RoutrelAudit.java -- Java 17+, needs com.fasterxml.jackson.core:jackson-databind on the classpath
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class RoutrelAudit {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN"); // step 2
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
static HttpRequest.Builder request(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
return b;
}
static ApiException errorFrom(int status, JsonNode env) {
JsonNode err = env.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers).build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (env == null) env = JSON.createObjectNode();
if (res.statusCode() / 100 != 2) throw errorFrom(res.statusCode(), env);
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2-6 go here (makeInput from step 4 goes next to api())
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 2
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token && !$token.empty?
headers.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
def api(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
env = {} unless env.is_a?(Hash)
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
// needs the curl extension
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // step 2
class ApiError extends Exception {
public $status;
public $apiCode;
public function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status;
$this->apiCode = $code;
}
}
function headers_for(array $extra = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api(string $method, string $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers_for($headers),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode($raw);
if (!is_object($env)) $env = new stdClass();
if ($status < 200 || $status > 299) {
$e = $env->error ?? new stdClass();
throw new ApiError($status, $e->code ?? null, $e->message ?? "HTTP $status");
}
return $env->data ?? null;
}
// Program.cs -- .NET 6+ console app (implicit usings on) with top-level statements; steps 2-6 (with the MakeInput local function) go ABOVE this class
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // step 2
public static readonly HttpClient Http = new HttpClient();
public class ApiException : Exception
{
public int Status;
public string? Code;
public ApiException(int status, string? code, string message) : base($"{status} {code}: {message}")
{
Status = status;
Code = code;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(Token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
public static ApiException ErrorFrom(HttpResponseMessage res, JsonNode? env)
{
var err = env?["error"];
return new ApiException((int)res.StatusCode, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? res.ReasonPhrase ?? "error");
}
public static async Task<JsonNode?> Call(string method, string path, object? body = null, IDictionary<string, string>? headers = null)
{
using var req = Request(method, path, body, headers);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode) throw ErrorFrom(res, env);
return env?["data"];
}
}
Step 2 — a token
Two kinds work:
-
Personal — needed for a run. All three lanes are metered in credits on
gpt-terra, so a run needs a signed-in (user) token (the page says “The server audit run is metered, so it needs an account.”, or the same of the data audit and the fix run) unless the estimate sayssponsor_enabled. Sign in and copy your token from /tokens.html, then export it asSKILLSAFE_TOKEN(the samples fall back to the placeholderYOUR_TOKEN). -
Guest — enough for
/meand/estimate.POST /guestwith{"slug": "routrel-audit"}and noAuthorizationheader, exactly assdk.jsguest()does. The response’sdatacarriestokenandguest_id. Reuse a token you already hold rather than minting a new one each time.
# personal token (needed for a run): copy it from /tokens.html, then
# export SKILLSAFE_TOKEN=... and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"routrel-audit"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if TOKEN in ("", "YOUR_TOKEN"):
TOKEN = "" # no Authorization header on /guest
guest = api("POST", "/guest", {"slug": "routrel-audit"})
TOKEN = guest["token"]
print("guest", guest.get("guest_id"))
// personal token (needed for a run): paste it from /tokens.html into TOKEN and skip this
if (!TOKEN || TOKEN === "YOUR_TOKEN") {
TOKEN = ""; // no Authorization header on /guest
const guest = await api("POST", "/guest", { slug: "routrel-audit" });
TOKEN = guest.token;
console.log("guest", guest.guest_id);
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if token == "" {
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := api("POST", "/guest", map[string]string{"slug": "routrel-audit"}, nil, &guest); err != nil {
panic(err)
}
token = guest.Token
fmt.Println("guest", guest.GuestID)
}
// inside main -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (token.isEmpty() || token.equals("YOUR_TOKEN")) {
token = ""; // no Authorization header on /guest
JsonNode guest = api("POST", "/guest", Map.of("slug", "routrel-audit"), null);
token = guest.path("token").asText();
System.out.println("guest " + guest.path("guest_id").asText());
}
# personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if $token.empty? || $token == "YOUR_TOKEN"
$token = "" # no Authorization header on /guest
guest = api("POST", "/guest", { slug: "routrel-audit" })
$token = guest["token"]
puts "guest #{guest["guest_id"]}"
end
// personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if ($TOKEN === "" || $TOKEN === "YOUR_TOKEN") {
$TOKEN = ""; // no Authorization header on /guest
$guest = api("POST", "/guest", ["slug" => "routrel-audit"]);
$TOKEN = $guest->token;
echo "guest {$guest->guest_id}\n";
}
// top-level statements (above the Api class) -- personal token (needed for a run): set SKILLSAFE_TOKEN (from /tokens.html) and skip this
if (Api.Token == "" || Api.Token == "YOUR_TOKEN")
{
Api.Token = ""; // no Authorization header on /guest
var guest = await Api.Call("POST", "/guest", new { slug = "routrel-audit" });
Api.Token = guest!["token"]!.GetValue<string>();
Console.WriteLine($"guest {guest["guest_id"]}");
}
Step 3 — GET /me
Who the token belongs to and what it can spend. sdk.js documents the shape as
{subject_type, subject_id, credits, profile?}. The page treats the caller as signed
in only when subject_type is "user", shows credits as the
balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it
with the estimate in step 4 before it lets you run.
api GET /me | jq '.data // .error'
me = api("GET", "/me")
print(me["subject_type"], me.get("subject_id"), me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits);
const signedIn = me.subject_type === "user";
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits any `json:"credits"`
}
if err := api("GET", "/me", nil, nil, &me); err != nil {
panic(err)
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("subject_id").asText() + " " + me.path("credits"));
boolean signedIn = me.path("subject_type").asText().equals("user");
me = api("GET", "/me")
puts [me["subject_type"], me["subject_id"], me["credits"]].join(" ")
signed_in = me["subject_type"] == "user"
$me = api("GET", "/me");
echo $me->subject_type, " ", $me->subject_id ?? "", " ", $me->credits ?? "", "\n";
$signedIn = $me->subject_type === "user";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["subject_id"]} {me?["credits"]}");
var signedIn = me?["subject_type"]?.GetValue<string>() == "user";
Step 4 — build the input, then POST /estimate
First a small makeInput that does what buildInput does: task,
then code as the paste the page would send, facts as a compact JSON string
(required), the note when it is not blank, and — for the fix lane only — the
audit_handoff string when the file carries one. It reads the file
build-check.js (or handoff.js) printed; that file’s
facts.lane must be the task you pass, since the facts are cut per lane. The
samples build the server input of the bookings example from
bookings-check.json; step 5 runs it, and step 6 reuses makeInput for the
wallet (fix) example. Then price it. The body of /estimate is the same input you will
send to /run; no job is created and nothing is charged — /estimate is
free. From the response the page reads:
| Field | Meaning |
|---|---|
hold_credits | A reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the server audit run” and adds that you are charged only for what the run actually uses, usually far less. What you pay is the job’s charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true). |
min_credits | The smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402. |
sponsor_enabled | When true, a caller who is not signed in may run; otherwise the page asks them to sign in. |
warnings | Input warnings. They do not refuse the call; the page shows them next to the price (“input warnings: ...”), so check them before you run. |
The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size
of code and facts, so read them from the call rather than hard-coding them.
# bookings-check.json: printed by `node build-check.js server bookings-paste.txt` (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does; build the file for this lane.
BODY=$(jq -c --arg note "Release candidate for the clinic's booking API. Anything to fix before it goes out?" \
'{task: "server", code, facts: (.facts | tojson), note: $note}' bookings-check.json)
# facts is required. No page check at hand? "{}" is accepted as a string, but the reply then has no page flags to answer.
# Fix lane after an audit: read handoff.js's output instead and add its string:
# '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'
api POST /estimate -d "$BODY" \
| jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'
def make_input(task, check_path, note=None):
"""The body the page sends: task, code, facts as a JSON STRING, an optional note, and
(fix lane only) the audit_handoff string -- every value a string."""
with open(check_path, encoding="utf-8") as f:
check = json.load(f) # build-check.js (or handoff.js) output
inp = {
"task": task,
"code": check["code"], # the paste as the page sends it
"facts": json.dumps(check["facts"], separators=(",", ":"), ensure_ascii=False), # a STRING, like JSON.stringify
}
n = (note or "").strip()
if n:
inp["note"] = n[:2000] # optional; the page caps it at 2000 chars
if task == "fix" and check.get("audit_handoff"):
inp["audit_handoff"] = check["audit_handoff"] # optional; already a JSON string
return inp
inp = make_input("server", "bookings-check.json",
"Release candidate for the clinic's booking API. Anything to fix before it goes out?")
est = api("POST", "/estimate", inp)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"), "warnings", est.get("warnings") or [])
// The body the page sends: task, code, facts as a JSON STRING, an optional note,
// and (fix lane only) the audit_handoff string -- every value a string.
function makeInput(task, checkPath, note) {
const check = JSON.parse(readFileSync(checkPath, "utf8")); // build-check.js (or handoff.js) output
const input = {
task,
code: check.code, // the paste as the page sends it
facts: JSON.stringify(check.facts), // a STRING: the page's free check
};
const n = (note || "").trim();
if (n) input.note = n.length > 2000 ? n.slice(0, 2000) : n; // optional; max 2000 chars
if (task === "fix" && check.audit_handoff) input.audit_handoff = check.audit_handoff; // optional
return input;
}
const input = makeInput("server", "bookings-check.json",
"Release candidate for the clinic's booking API. Anything to fix before it goes out?");
const est = await api("POST", "/estimate", input);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsored", est.sponsor_enabled, "warnings", est.warnings || []);
// makeInput builds the body the page sends: task, code, facts as a JSON STRING,
// an optional note and (fix lane only) the audit_handoff string.
makeInput := func(task, checkPath, note string) map[string]any {
raw, err := os.ReadFile(checkPath) // build-check.js (or handoff.js) output
if err != nil {
panic(err)
}
var check struct {
Code string `json:"code"`
Facts json.RawMessage `json:"facts"`
AuditHandoff string `json:"audit_handoff"`
}
if err := json.Unmarshal(raw, &check); err != nil {
panic(err)
}
var facts bytes.Buffer
if err := json.Compact(&facts, check.Facts); err != nil { // keeps key order; travels as a STRING
panic(err)
}
in := map[string]any{"task": task, "code": check.Code, "facts": facts.String()}
if n := []rune(strings.TrimSpace(note)); len(n) > 0 {
if len(n) > 2000 {
n = n[:2000] // optional; the page caps it at 2000 chars
}
in["note"] = string(n)
}
if task == "fix" && check.AuditHandoff != "" {
in["audit_handoff"] = check.AuditHandoff // optional; already a JSON string
}
return in
}
input := makeInput("server", "bookings-check.json",
"Release candidate for the clinic's booking API. Anything to fix before it goes out?")
var est struct {
HoldCredits any `json:"hold_credits"`
MinCredits any `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
if err := api("POST", "/estimate", input, nil, &est); err != nil {
panic(err)
}
fmt.Printf("hold %v min %v sponsored %v warnings %v\n", est.HoldCredits, est.MinCredits, est.SponsorEnabled, est.Warnings)
// ---- add to the RoutrelAudit class, next to api(): the body the page sends
static Map<String, Object> makeInput(String task, String checkPath, String note) throws Exception {
JsonNode check = JSON.readTree(Files.readString(Path.of(checkPath))); // build-check.js (or handoff.js) output
Map<String, Object> in = new LinkedHashMap<>();
in.put("task", task);
in.put("code", check.path("code").asText()); // the paste as the page sends it
in.put("facts", JSON.writeValueAsString(check.path("facts"))); // a STRING, compact
String n = note == null ? "" : note.strip();
if (!n.isEmpty()) in.put("note", n.length() > 2000 ? n.substring(0, 2000) : n); // optional; max 2000 chars
if (task.equals("fix") && check.hasNonNull("audit_handoff")) in.put("audit_handoff", check.get("audit_handoff").asText());
return in;
}
// ---- inside main
Map<String, Object> input = makeInput("server", "bookings-check.json",
"Release candidate for the clinic's booking API. Anything to fix before it goes out?");
JsonNode est = api("POST", "/estimate", input, null);
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
# The body the page sends: task, code, facts as a JSON STRING, an optional note,
# and (fix lane only) the audit_handoff string -- every value a string.
def make_input(task, check_path, note = nil)
check = JSON.parse(File.read(check_path, encoding: "UTF-8")) # build-check.js (or handoff.js) output
input = {
task: task,
code: check["code"], # the paste as the page sends it
facts: JSON.generate(check["facts"]) # a STRING, compact
}
n = note.to_s.strip
input[:note] = n[0, 2000] unless n.empty? # optional; max 2000 chars
input[:audit_handoff] = check["audit_handoff"] if task == "fix" && !check["audit_handoff"].to_s.empty?
input
end
input = make_input("server", "bookings-check.json",
"Release candidate for the clinic's booking API. Anything to fix before it goes out?")
est = api("POST", "/estimate", input)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} " \
"sponsored #{est["sponsor_enabled"]} warnings #{(est["warnings"] || []).inspect}"
// The body the page sends: task, code, facts as a JSON STRING, an optional note,
// and (fix lane only) the audit_handoff string -- every value a string.
function make_input(string $task, string $checkPath, string $note = ""): array {
// decode to objects (not arrays) so an empty {} stays {} when re-encoded
$check = json_decode(file_get_contents($checkPath)); // build-check.js (or handoff.js) output
$input = [
"task" => $task,
"code" => $check->code, // the paste as the page sends it
"facts" => json_encode($check->facts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // a STRING
];
$n = trim($note);
if ($n !== "") $input["note"] = mb_substr($n, 0, 2000); // optional; max 2000 chars
if ($task === "fix" && !empty($check->audit_handoff)) $input["audit_handoff"] = $check->audit_handoff;
return $input;
}
$input = make_input("server", "bookings-check.json",
"Release candidate for the clinic's booking API. Anything to fix before it goes out?");
$est = api("POST", "/estimate", $input);
echo "hold ", $est->hold_credits ?? "-", " min ", $est->min_credits ?? "-",
" sponsored ", json_encode($est->sponsor_enabled ?? false),
" warnings ", json_encode($est->warnings ?? []), "\n";
// The body the page sends: task, code, facts as a JSON STRING, an optional note,
// and (fix lane only) the audit_handoff string -- every value a string.
static Dictionary<string, string> MakeInput(string task, string checkPath, string? note = null)
{
var compact = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var check = JsonNode.Parse(File.ReadAllText(checkPath))!; // build-check.js (or handoff.js) output
var input = new Dictionary<string, string>
{
["task"] = task,
["code"] = check["code"]!.GetValue<string>(), // the paste as the page sends it
["facts"] = check["facts"]!.ToJsonString(compact), // a STRING, compact
};
var n = (note ?? "").Trim();
if (n.Length > 0) input["note"] = n.Length > 2000 ? n[..2000] : n; // optional; max 2000 chars
var handoff = check["audit_handoff"]?.GetValue<string>();
if (task == "fix" && !string.IsNullOrEmpty(handoff)) input["audit_handoff"] = handoff;
return input;
}
var input = MakeInput("server", "bookings-check.json",
"Release candidate for the clinic's booking API. Anything to fix before it goes out?");
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsored {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString() ?? "[]"}");
Step 5 — POST /run, then poll the job (server audit)
Send the same input to /run with an Idempotency-Key header, using a
personal token. Its data carries a job_id. Poll
GET /jobs/{job_id} the way sdk.js waitForJob does: every
second until status is succeeded or failed, giving up on
the client side after 180 seconds. On the finished job, read:
output.output— the model’s reply as a string: the JSON object from the output contract of the lane you asked for. Parse it yourself (from the first{to the last}). The samples print the verdict and headline, each area’s status, each finding’sid,file,line,severityand number of edits, then each flag answer. Keep the text:recheck.jsreads it.truncated—truewhen the reply was cut short because the balance ran out. The page then closes the cut-off JSON, shows what arrived as a partial result (“N of 9 sections recovered” for an audit, of 8 for a fix run), and says it was cut short by the available balance.charged_credits— the real cost of the run, as opposed to the estimate’shold_creditsreservation. The page re-reads/meafterwards for the new balance.error— on afailedjob.
Idempotency key. Resending a request with the same key must not start a second
paid run, so derive the key from the input, not from the clock — and include the lane, so a
server audit, a data audit and a fix run of the same paste never share a key. The page uses
routrel-audit:<lane>:<inputHash>:a<attempt> (app.js
run), where <lane> is server, data or
fix, <inputHash> is a 32-bit djb2 hash of
JSON.stringify(input) in hex, a dash, and the string’s length in hex
(app.js inputHash, over the input without
retry_note), and <attempt> is 1 for a normal run and
2 for the automatic reformat retry (which also adds retry_note). For the
three worked inputs above that is routrel-audit:server:e7ea62e2-3b88:a1 (bookings server
audit), routrel-audit:data:1ad72fe8-3b84:a1 (bookings data audit) and
routrel-audit:fix:5820c181-391b:a1 (wallet fix). The format is the page’s convention,
not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256
prefix.
The page itself uses /run-stream (step 6), not /run; both take the
same body and header. For the data audit, run the same code with
node build-check.js data bookings-paste.txt > bookings-data-check.json,
makeInput("data", "bookings-data-check.json", note) and a key that starts
routrel-audit:data:; the reply has the same shape, with the three data areas
(transactions, queries, setup).
KEY="routrel-audit:server:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"
JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')
for i in $(seq 180); do # 1 s interval, 180 s client-side limit
J=$(api GET "/jobs/$JOB")
S=$(printf '%s' "$J" | jq -r '.data.status')
if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
sleep 1
done
printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
| jq '{verdict, headline, areas: [(.areas // [])[] | {area, status}],
findings: [(.findings // [])[] | {id, file, line, severity, edits: ((.edits // []) | length)}],
flag_answers: [(.flag_answers // [])[] | {flag, status}]}'
body = json.dumps(inp)
key = "routrel-audit:server:" + hashlib.sha256(body.encode()).hexdigest()[:16] + ":a1"
job_id = api("POST", "/run", inp, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180 # 1 s interval, 180 s client-side limit
while True:
job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(f"run failed: {job.get('error')}")
print("truncated:", job.get("truncated") is True, "charged:", job.get("charged_credits"))
text = (job.get("output") or {}).get("output") or ""
reply = json.loads(text[text.index("{"): text.rindex("}") + 1])
print(reply["verdict"], "-", reply["headline"])
for s in reply.get("areas", []):
print("area", s["area"], s["status"])
for f in reply.get("findings", []):
print(f["id"], f["file"], f.get("line"), f["severity"], len(f.get("edits") or []), "edit(s)")
for a in reply.get("flag_answers", []):
print("answer", a["flag"], a["status"])
const key = "routrel-audit:server:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });
let job;
const deadline = Date.now() + 180000; // 1 s interval, 180 s client-side limit
for (;;) {
job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("run failed: " + JSON.stringify(job.error));
console.log("truncated:", job.truncated === true, "charged:", job.charged_credits);
const text = (job.output && job.output.output) || "";
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
console.log(reply.verdict, "-", reply.headline);
for (const s of reply.areas || []) console.log("area", s.area, s.status);
for (const f of reply.findings || []) console.log(f.id, f.file, f.line, f.severity, (f.edits || []).length, "edit(s)");
for (const a of reply.flag_answers || []) console.log("answer", a.flag, a.status);
inputJSON, _ := json.Marshal(input)
sum := sha256.Sum256(inputJSON)
key := "routrel-audit:server:" + hex.EncodeToString(sum[:8]) + ":a1"
var started struct {
JobID string `json:"job_id"`
}
if err := api("POST", "/run", input, map[string]string{"Idempotency-Key": key}, &started); err != nil {
panic(err)
}
type jobT struct {
Status string `json:"status"`
Output *struct {
Output string `json:"output"`
} `json:"output"`
Truncated bool `json:"truncated"`
ChargedCredits any `json:"charged_credits"`
Error any `json:"error"`
}
var job jobT
deadline := time.Now().Add(180 * time.Second) // 1 s interval, 180 s client-side limit
for {
job = jobT{}
if err := api("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil, &job); err != nil {
panic(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
panic(fmt.Sprintf("run failed: %v", job.Error))
}
fmt.Println("truncated:", job.Truncated, "charged:", job.ChargedCredits)
text := ""
if job.Output != nil {
text = job.Output.Output
}
var reply map[string]any
if a, b := strings.Index(text, "{"), strings.LastIndex(text, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(text[a:b+1]), &reply); err != nil {
panic(err)
}
}
fmt.Println(reply["verdict"], "-", reply["headline"])
areas, _ := reply["areas"].([]any)
for _, e := range areas {
s, _ := e.(map[string]any)
fmt.Println("area", s["area"], s["status"])
}
findings, _ := reply["findings"].([]any)
for _, e := range findings {
f, _ := e.(map[string]any)
edits, _ := f["edits"].([]any)
fmt.Println(f["id"], f["file"], f["line"], f["severity"], len(edits), "edit(s)")
}
answers, _ := reply["flag_answers"].([]any)
for _, e := range answers {
a, _ := e.(map[string]any)
fmt.Println("answer", a["flag"], a["status"])
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(input));
String key = "routrel-audit:server:" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
String jobId = api("POST", "/run", input, Map.of("Idempotency-Key", key)).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000; // 1 s interval, 180 s client-side limit
while (true) {
job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.path("error"));
System.out.println("truncated: " + job.path("truncated").asBoolean(false) + ", charged: " + job.path("charged_credits"));
String text = job.path("output").path("output").asText("");
JsonNode reply = JSON.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(reply.path("verdict").asText() + " - " + reply.path("headline").asText());
for (JsonNode s : reply.path("areas")) {
System.out.println("area " + s.path("area").asText() + " " + s.path("status").asText());
}
for (JsonNode f : reply.path("findings")) {
System.out.println(f.path("id").asText() + " " + f.path("file").asText() + " " + f.path("line") + " "
+ f.path("severity").asText() + " " + f.path("edits").size() + " edit(s)");
}
for (JsonNode a : reply.path("flag_answers")) {
System.out.println("answer " + a.path("flag").asText() + " " + a.path("status").asText());
}
key = "routrel-audit:server:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job_id = api("POST", "/run", input, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180 # 1 s interval, 180 s client-side limit
job = nil
loop do
job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "run failed: #{job["error"].inspect}" if job["status"] == "failed"
puts "truncated: #{job["truncated"] == true}, charged: #{job["charged_credits"]}"
text = (job["output"] || {})["output"].to_s
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{reply["verdict"]} - #{reply["headline"]}"
(reply["areas"] || []).each { |s| puts "area #{s["area"]} #{s["status"]}" }
(reply["findings"] || []).each { |f| puts "#{f["id"]} #{f["file"]} #{f["line"].inspect} #{f["severity"]} #{(f["edits"] || []).size} edit(s)" }
(reply["flag_answers"] || []).each { |a| puts "answer #{a["flag"]} #{a["status"]}" }
$key = "routrel-audit:server:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = api("POST", "/run", $input, ["Idempotency-Key" => $key])->job_id;
$deadline = time() + 180; // 1 s interval, 180 s client-side limit
while (true) {
$job = api("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job->status, ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job->status === "failed") throw new RuntimeException("run failed: " . json_encode($job->error ?? null));
echo "truncated: ", json_encode(($job->truncated ?? false) === true), ", charged: ", $job->charged_credits ?? "-", "\n";
$text = $job->output->output ?? "";
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1));
echo $reply->verdict, " - ", $reply->headline, "\n";
foreach ($reply->areas ?? [] as $s) echo "area ", $s->area, " ", $s->status, "\n";
foreach ($reply->findings ?? [] as $f) echo $f->id, " ", $f->file, " ", json_encode($f->line ?? null), " ", $f->severity, " ", count($f->edits ?? []), " edit(s)\n";
foreach ($reply->flag_answers ?? [] as $a) echo "answer ", $a->flag, " ", $a->status, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input)));
var key = "routrel-audit:server:" + Convert.ToHexString(digest, 0, 8).ToLowerInvariant() + ":a1";
var started = await Api.Call("POST", "/run", input, new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started!["job_id"]!.GetValue<string>();
JsonNode job;
var deadline = DateTime.UtcNow.AddSeconds(180); // 1 s interval, 180 s client-side limit
while (true)
{
job = (await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var s = job["status"]?.GetValue<string>();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"run failed: {job["error"]?.ToJsonString()}");
var truncated = job["truncated"] is JsonValue t && t.TryGetValue(out bool tb) && tb;
Console.WriteLine($"truncated: {truncated}, charged: {job["charged_credits"]}");
var text = job["output"]?["output"]?.GetValue<string>() ?? "";
var reply = JsonNode.Parse(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{reply["verdict"]} - {reply["headline"]}");
foreach (var s in reply["areas"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"area {s?["area"]} {s?["status"]}");
foreach (var f in reply["findings"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"{f?["id"]} {f?["file"]} {f?["line"]?.ToJsonString() ?? "null"} {f?["severity"]} {f?["edits"]?.AsArray().Count ?? 0} edit(s)");
foreach (var a in reply["flag_answers"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"answer {a?["flag"]} {a?["status"]}");
Step 6 — POST /run-stream (Server-Sent Events, fix)
The alternative to step 5, and what the page uses: same kind of body, same
Idempotency-Key header, one request. Run one or the other for a given input, not
both. To show another lane, the samples build the fix input of the
wallet example (with its own key) and stream it; to stream the server (or data) input instead,
pass the step 4 input and key. The response is text/event-stream. sdk.js splits
it into frames on a blank line, takes the event name from an event: line (default
message) and concatenates the data: lines, skips frames with no data or
with data that is not JSON, and handles these events:
| Event | Data | What the SDK does |
|---|---|---|
delta | {text} | Passes text to onDelta. |
job | job info | Passes it to onJob (the page uses it to advance its progress display). |
done | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. |
pending | a payload | Treated exactly like done. If it carries a job_id and no output, poll that job as in step 5. |
error | {message, code, job_id} | After the stream ends, raises an error with those fields. |
If the response is not text/event-stream, it is an ordinary JSON envelope:
the SDK returns its data (an idempotent replay returns this way) or raises the
error on a non-2xx status. Handle both shapes.
From the final payload the page reads output.output (falling back to the
concatenated delta text), truncated and charged_credits.
Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the
final payload; if the stream fails part-way, it tries to recover a partial reply from whatever
delta text arrived. The samples print the verdict and headline, each returned file’s path and
size, each change (function, flags and change), each
verification step, each open item and each question. To put the files into the paste and re-check
them the way the page’s “Put every file into the paste” does, keep the reply text
and run recheck.js with the lane fix. To fix code you audited, read handoff.js’s output instead of
wallet-check.json: makeInput then carries its audit_handoff.
# the fix worked example: wallet-check.json from `node build-check.js fix wallet-paste.txt`
# (for audited code, read handoff.js's output instead; the + (...) adds its audit_handoff)
FBODY=$(jq -c --arg note "Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release." \
'{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
wallet-check.json)
FKEY="routrel-audit:fix:$(printf '%s' "$FBODY" | shasum -a 256 | cut -c1-16):a1"
# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $FKEY" -d "$FBODY")
# frames look like:
# event: job
# data: {...}
#
# event: done
# data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"fix\",...}"}}
# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
| awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
| jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
files: [(.files // [])[] | {path, characters: (.content | length)}],
changes: [(.changes // [])[] | {function, flags, change}], verify, open, questions}'
# the fix worked example: wallet-check.json from build-check.js
# (for audited code, pass handoff.js's output instead: make_input adds its audit_handoff)
fix_inp = make_input("fix", "wallet-check.json",
"Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.")
fix_key = "routrel-audit:fix:" + hashlib.sha256(json.dumps(fix_inp).encode()).hexdigest()[:16] + ":a1"
h = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN, "Idempotency-Key": fix_key}
done, failure, streamed = None, None, ""
with requests.post(BASE + "/run-stream", headers=h, data=json.dumps(fix_inp), stream=True) as r:
if "text/event-stream" not in r.headers.get("content-type", ""):
try:
env = r.json()
except ValueError:
env = {}
if not r.ok:
err = env.get("error") or {}
raise ApiError(r.status_code, err.get("code"), err.get("message") or r.reason)
done = env.get("data")
else:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line == "": # a blank line ends a frame
if data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if event == "delta" and payload is not None:
streamed += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if failure:
raise SystemExit(f"{failure.get('code')}: {failure.get('message')} (job {failure.get('job_id')})")
done = done or {}
full = (done.get("output") or {}).get("output") or streamed
print("truncated:", done.get("truncated") is True, "charged:", done.get("charged_credits"))
result = json.loads(full[full.index("{"): full.rindex("}") + 1])
print(result["verdict"], "-", result["headline"])
for f in result.get("files", []):
print("file", f["path"], len(f.get("content") or ""), "characters")
for c in result.get("changes", []):
print("change", c.get("function"), ",".join(c.get("flags") or []) or "-", "-", c.get("change"))
for step in result.get("verify", []):
print("verify", step)
for o in result.get("open", []):
print("open", o["item"], "-", o.get("reason"))
for q in result.get("questions", []):
print("question", q)
// the fix worked example: wallet-check.json from `node build-check.js fix wallet-paste.txt`
// (for audited code, pass handoff.js's output instead: makeInput adds its audit_handoff)
const fixInput = makeInput("fix", "wallet-check.json",
"Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.");
const fixKey = "routrel-audit:fix:" +
createHash("sha256").update(JSON.stringify(fixInput)).digest("hex").slice(0, 16) + ":a1";
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN, "Idempotency-Key": fixKey },
body: JSON.stringify(fixInput),
});
let done = null, failure = null, streamed = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((json.error && json.error.message) || res.statusText);
done = json.data;
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") streamed += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw new Error(`${failure.code}: ${failure.message} (job ${failure.job_id})`);
const full = (done && done.output && done.output.output) || streamed;
console.log("truncated:", !!(done && done.truncated), "charged:", done && done.charged_credits);
const result = JSON.parse(full.slice(full.indexOf("{"), full.lastIndexOf("}") + 1));
console.log(result.verdict, "-", result.headline);
for (const f of result.files || []) console.log("file", f.path, (f.content || "").length, "characters");
for (const c of result.changes || []) console.log("change", c.function, (c.flags || []).join(",") || "-", "-", c.change);
for (const step of result.verify || []) console.log("verify", step);
for (const o of result.open || []) console.log("open", o.item, "-", o.reason);
for (const q of result.questions || []) console.log("question", q);
// the fix worked example: wallet-check.json from `node build-check.js fix wallet-paste.txt`
// (for audited code, pass handoff.js's output instead: makeInput adds its audit_handoff)
fixInput := makeInput("fix", "wallet-check.json",
"Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.")
fixJSON, _ := json.Marshal(fixInput)
fixSum := sha256.Sum256(fixJSON)
fixKey := "routrel-audit:fix:" + hex.EncodeToString(fixSum[:8]) + ":a1"
sreq, err := newRequest("POST", "/run-stream", fixInput, map[string]string{"Idempotency-Key": fixKey})
if err != nil {
panic(err)
}
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var done, failure map[string]any
var streamed strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env envelope
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode < 200 || sres.StatusCode > 299 {
panic(errorFrom(sres, env))
}
_ = json.Unmarshal(env.Data, &done)
} else {
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // the done frame can be long
event, data := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "": // a blank line ends a frame
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if t, ok := p["text"].(string); ok {
streamed.WriteString(t)
}
case "job":
fmt.Println("job", p)
case "done", "pending":
done = p
case "error":
failure = p
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
}
if failure != nil {
panic(fmt.Sprintf("%v: %v (job %v)", failure["code"], failure["message"], failure["job_id"]))
}
full := streamed.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
fmt.Println("truncated:", done["truncated"] == true, "charged:", done["charged_credits"])
var result map[string]any
if a, b := strings.Index(full, "{"), strings.LastIndex(full, "}"); a >= 0 && b > a {
if err := json.Unmarshal([]byte(full[a:b+1]), &result); err != nil {
panic(err)
}
}
fmt.Println(result["verdict"], "-", result["headline"])
files, _ := result["files"].([]any)
for _, e := range files {
f, _ := e.(map[string]any)
c, _ := f["content"].(string)
fmt.Println("file", f["path"], len([]rune(c)), "characters")
}
changes, _ := result["changes"].([]any)
for _, e := range changes {
c, _ := e.(map[string]any)
var flags []string
if fs, ok := c["flags"].([]any); ok {
for _, fl := range fs {
flags = append(flags, fmt.Sprint(fl))
}
}
fl := strings.Join(flags, ",")
if fl == "" {
fl = "-"
}
fmt.Println("change", c["function"], fl, "-", c["change"])
}
verify, _ := result["verify"].([]any)
for _, st := range verify {
fmt.Println("verify", st)
}
open, _ := result["open"].([]any)
for _, e := range open {
o, _ := e.(map[string]any)
fmt.Println("open", o["item"], "-", o["reason"])
}
questions, _ := result["questions"].([]any)
for _, q := range questions {
fmt.Println("question", q)
}
// the fix worked example: wallet-check.json from `node build-check.js fix wallet-paste.txt`
// (for audited code, pass handoff.js's output instead: makeInput adds its audit_handoff)
Map<String, Object> fixInput = makeInput("fix", "wallet-check.json",
"Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.");
byte[] fixDigest = MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsBytes(fixInput));
String fixKey = "routrel-audit:fix:" + HexFormat.of().formatHex(fixDigest, 0, 8) + ":a1";
HttpRequest sreq = request("POST", "/run-stream", fixInput, Map.of("Idempotency-Key", fixKey)).build();
HttpResponse<Stream<String>> sres = HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines());
JsonNode done = null, failure = null;
StringBuilder streamed = new StringBuilder();
if (!sres.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
JsonNode env = JSON.readTree(sres.body().collect(Collectors.joining("\n")));
if (env == null) env = JSON.createObjectNode();
if (sres.statusCode() / 100 != 2) throw errorFrom(sres.statusCode(), env);
done = env.path("data");
} else {
String event = "message";
StringBuilder data = new StringBuilder();
Iterator<String> it = sres.body().iterator();
while (it.hasNext()) {
String line = it.next();
if (line.isEmpty()) { // a blank line ends a frame
if (data.length() > 0) {
JsonNode p = null;
try { p = JSON.readTree(data.toString()); } catch (Exception ignored) { }
if (p != null) {
switch (event) {
case "delta": streamed.append(p.path("text").asText("")); break;
case "job": System.out.println("job " + p); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
default: break;
}
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
}
}
if (failure != null) {
throw new RuntimeException(failure.path("code").asText() + ": " + failure.path("message").asText()
+ " (job " + failure.path("job_id").asText() + ")");
}
String full = done == null ? "" : done.path("output").path("output").asText("");
if (full.isEmpty()) full = streamed.toString();
System.out.println("truncated: " + (done != null && done.path("truncated").asBoolean(false))
+ ", charged: " + (done == null ? "-" : done.path("charged_credits").toString()));
JsonNode result = JSON.readTree(full.substring(full.indexOf('{'), full.lastIndexOf('}') + 1));
System.out.println(result.path("verdict").asText() + " - " + result.path("headline").asText());
for (JsonNode f : result.path("files")) {
System.out.println("file " + f.path("path").asText() + " " + f.path("content").asText("").codePointCount(0, f.path("content").asText("").length()) + " characters");
}
for (JsonNode c : result.path("changes")) {
List<String> flags = new ArrayList<>();
c.path("flags").forEach(fl -> flags.add(fl.asText()));
System.out.println("change " + c.path("function").asText() + " " + (flags.isEmpty() ? "-" : String.join(",", flags))
+ " - " + c.path("change").asText());
}
for (JsonNode step : result.path("verify")) System.out.println("verify " + step.asText());
for (JsonNode o : result.path("open")) System.out.println("open " + o.path("item").asText() + " - " + o.path("reason").asText());
for (JsonNode q : result.path("questions")) System.out.println("question " + q.asText());
# the fix worked example: wallet-check.json from build-check.js
# (for audited code, pass handoff.js's output instead: make_input adds its audit_handoff)
fix_input = make_input("fix", "wallet-check.json",
"Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.")
fix_key = "routrel-audit:fix:#{Digest::SHA256.hexdigest(JSON.generate(fix_input))[0, 16]}:a1"
uri, req = build_request("POST", "/run-stream", fix_input, { "Idempotency-Key" => fix_key })
done = failure = nil
streamed = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["content-type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
raise ApiError.new(res.code.to_i, env.dig("error", "code"), env.dig("error", "message") || res.message)
end
done = env["data"]
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
frame = buf.slice!(0, i + 2)
event = "message"
data = +""
frame.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "job" then puts "job #{payload.inspect}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise "#{failure["code"]}: #{failure["message"]} (job #{failure["job_id"]})" if failure
done ||= {}
full = (done["output"] || {})["output"].to_s
full = streamed if full.empty?
puts "truncated: #{done["truncated"] == true}, charged: #{done["charged_credits"]}"
result = JSON.parse(full[full.index("{")..full.rindex("}")])
puts "#{result["verdict"]} - #{result["headline"]}"
(result["files"] || []).each { |f| puts "file #{f["path"]} #{f["content"].to_s.length} characters" }
(result["changes"] || []).each do |c|
flags = (c["flags"] || []).join(",")
puts "change #{c["function"]} #{flags.empty? ? "-" : flags} - #{c["change"]}"
end
(result["verify"] || []).each { |step| puts "verify #{step}" }
(result["open"] || []).each { |o| puts "open #{o["item"]} - #{o["reason"]}" }
(result["questions"] || []).each { |q| puts "question #{q}" }
// the fix worked example: wallet-check.json from `node build-check.js fix wallet-paste.txt`
// (for audited code, pass handoff.js's output instead: make_input adds its audit_handoff)
$fixInput = make_input("fix", "wallet-check.json",
"Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.");
$fixKey = "routrel-audit:fix:" . substr(hash("sha256", json_encode($fixInput)), 0, 16) . ":a1";
$done = null; $failure = null; $streamed = ""; $buf = ""; $ctype = "";
$handle = function (string $event, string $data) use (&$done, &$failure, &$streamed) {
$p = json_decode($data);
if ($p === null) return; // not JSON: skipped, as sdk.js does
switch ($event) {
case "delta": $streamed .= $p->text ?? ""; break;
case "job": echo "job ", json_encode($p), "\n"; break;
case "done": case "pending": $done = $p; break;
case "error": $failure = $p; break;
}
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers_for(["Idempotency-Key" => $fixKey]),
CURLOPT_POSTFIELDS => json_encode($fixInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$ctype) {
if (stripos($line, "content-type:") === 0) $ctype = strtolower(trim(substr($line, 13)));
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$ctype, $handle) {
$buf .= $chunk; // collects the response in memory
if (strpos($ctype, "text/event-stream") === false) return strlen($chunk); // plain JSON: parse at the end
while (($i = strpos($buf, "\n\n")) !== false) {
$frame = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data !== "") $handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
$env = json_decode($buf) ?? new stdClass();
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env->error->code ?? null, $env->error->message ?? "HTTP $status");
}
$done = $env->data ?? null;
}
if ($failure) throw new RuntimeException(($failure->code ?? "") . ": " . ($failure->message ?? "") . " (job " . ($failure->job_id ?? "") . ")");
$full = $done->output->output ?? "";
if ($full === "") $full = $streamed;
echo "truncated: ", json_encode(($done->truncated ?? false) === true), ", charged: ", $done->charged_credits ?? "-", "\n";
$result = json_decode(substr($full, strpos($full, "{"), strrpos($full, "}") - strpos($full, "{") + 1));
echo $result->verdict, " - ", $result->headline, "\n";
foreach ($result->files ?? [] as $f) echo "file ", $f->path, " ", mb_strlen($f->content ?? ""), " characters\n";
foreach ($result->changes ?? [] as $c) {
$flags = implode(",", $c->flags ?? []);
echo "change ", $c->function ?? "", " ", $flags === "" ? "-" : $flags, " - ", $c->change ?? "", "\n";
}
foreach ($result->verify ?? [] as $step) echo "verify ", $step, "\n";
foreach ($result->open ?? [] as $o) echo "open ", $o->item, " - ", $o->reason ?? "", "\n";
foreach ($result->questions ?? [] as $q) echo "question ", $q, "\n";
// the fix worked example: wallet-check.json from `node build-check.js fix wallet-paste.txt`
// (for audited code, pass handoff.js's output instead: MakeInput adds its audit_handoff)
var fixInput = MakeInput("fix", "wallet-check.json",
"Transfers sometimes leave balances wrong, and last night every wallet ended up with the same balance. Fix the data layer. The mobile backend calls WalletRepository.findByOwner and reads the WalletEntity it returns - keep that function's signature exactly as it is this release.");
var fixDigest = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fixInput)));
var fixKey = "routrel-audit:fix:" + Convert.ToHexString(fixDigest, 0, 8).ToLowerInvariant() + ":a1";
using var sreq = Api.Request("POST", "/run-stream", fixInput, new Dictionary<string, string> { ["Idempotency-Key"] = fixKey });
using var sres = await Api.Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
JsonNode? done = null, failure = null;
var streamed = new StringBuilder();
if (sres.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
JsonNode? env = null;
try { env = JsonNode.Parse(await sres.Content.ReadAsStringAsync()); } catch (JsonException) { }
if (!sres.IsSuccessStatusCode) throw Api.ErrorFrom(sres, env);
done = env?["data"];
}
else
{
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var ev = "message";
var data = new StringBuilder();
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0) // a blank line ends a frame
{
if (data.Length > 0)
{
JsonNode? p = null;
try { p = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (p != null)
{
switch (ev)
{
case "delta": streamed.Append(p["text"]?.GetValue<string>() ?? ""); break;
case "job": Console.WriteLine($"job {p.ToJsonString()}"); break;
case "done": case "pending": done = p; break;
case "error": failure = p; break;
}
}
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
}
if (failure != null) throw new Exception($"{failure["code"]}: {failure["message"]} (job {failure["job_id"]})");
var full = done?["output"]?["output"]?.GetValue<string>();
if (string.IsNullOrEmpty(full)) full = streamed.ToString();
var cut = done?["truncated"] is JsonValue tv && tv.TryGetValue(out bool cutShort) && cutShort;
Console.WriteLine($"truncated: {cut}, charged: {done?["charged_credits"]}");
var result = JsonNode.Parse(full[full.IndexOf('{')..(full.LastIndexOf('}') + 1)])!;
Console.WriteLine($"{result["verdict"]} - {result["headline"]}");
foreach (var f in result["files"]?.AsArray() ?? new JsonArray())
Console.WriteLine($"file {f?["path"]} {(f?["content"]?.GetValue<string>() ?? "").Length} characters");
foreach (var c in result["changes"]?.AsArray() ?? new JsonArray())
{
var flags = string.Join(",", (c?["flags"]?.AsArray() ?? new JsonArray()).Select(fl => fl?.GetValue<string>()));
Console.WriteLine($"change {c?["function"]} {(flags == "" ? "-" : flags)} - {c?["change"]}");
}
foreach (var step in result["verify"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"verify {step}");
foreach (var o in result["open"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"open {o?["item"]} - {o?["reason"]}");
foreach (var q in result["questions"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"question {q}");
What the page does that you now do
- Reads the paste in the browser and builds
codeand a lane-cutfactsfrom it for free (ktorkit.js), joining dropped files under// file:lines (the build file first, then migrations, thenApplication.kt) and cutting a large paste on whole files with a// [routrel-audit: not sent: ...]line; you send the same strings, andfactsis required. - Prices every input with
/estimate(thehold_creditsreservation, themin_creditsfloor) and refuses to run belowmin_credits, or on a paste with no Kotlin it can read. - Sends each attempt with an input- and lane-derived
Idempotency-Key, and retries once withretry_notewhen a reply does not parse; after a run it re-reads/mefor the balance left oncecharged_creditssettled. - Closes a cut-off JSON reply and shows what arrived when
truncatedis true or the stream ends early. - Hands a server audit to the data audit with its edits applied, and either audit to the fix lane as
audit_handoff, with every edit applied to the paste first, and only while that edited paste is unchanged. - Re-checks the reply against the code (
recon.js), in the browser: files, lines, evidence, catalogue ids of the lane, every edit applied in order and the free check re-run after each finding, flag answers and verdict; the fixed files put back, re-checked and matched against the changes list and the open items, with every fun still there and stillsuspendor not; and repeated credentials.recheck.jsabove runs the same checks for you.
Or skip all of this and use Routrel Audit in the browser: the check, the flags, the route, plugin and transaction inventories, putting an edit or the fixed files back into the paste, and the Markdown and CSV exports are free.