← PyErr Desk / API
Tokens

Drive PyErr Desk from your own code

The page reads pasted Python in the browser for free — one or more files joined into one paste, each introduced by a # file: <path> line — with its line numbers. It lexes strings and comments out first, rebuilds the block structure, matches every except to its try, inventories every except block and what its handler really does, every raise, every exception class and every file, connection, socket, session, client, pool or temporary file with how it is released, and runs 20 deterministic checks drawn from Python error-handling and resource-management practice (catalogue ids PE-01..PE-20, plus PE-21 for context): bare and broad excepts that swallow errors or drop the traceback, specific exceptions ignored without a comment, failed items dropped inside a batch loop, failures turned into None, lost exception chains, raise Exception, messages that say nothing, assert as validation, oversized try blocks, BaseException subclasses, resources never closed or closed only on success, an __exit__ that suppresses everything, a @contextmanager whose cleanup is skipped, return in finally, a commit with no rollback, += string building and __del__ cleanup. Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "harden", 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 four areas (exceptions, validation, partial failure, resources), answers every flag and gives exact before/after edits per finding, or the whole hardened file(s) with the list of changes and an exception catalogue.

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. Both lanes run on the gpt-terra model with a 10% publisher markup.

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 (“The run failed: ...”).

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe 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.
401UNAUTHORIZEDNo 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.
402INSUFFICIENT_CREDITSThe 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.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn 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).
429RATE_LIMITEDToo 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:

WhatWhat happens
event: error on /run-streamThe 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 sThe 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: two lanes

task routes the run, and it takes exactly two values. Each lane has its own reply contract, and a reply never mixes them. The skill’s instructions tell the model to do the audit lane when task is missing or anything else (and the page’s own Errkit.buildInput turns any lane that is not "harden" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the module the way a senior engineer does before it runs unattended: says what the code does and how it fails today, rates the four areas (exceptions, validation, partial failure, resources) as good, partial, poor or n_a, confirms or dismisses every page flag, adds what text rules cannot see, and gives the verbatim evidence and exact before/after edits for each finding, listed in the order to apply them — what loses errors or data first (swallowed failures, a suppressing __exit__, return in finally, partial commits), then leaks and lost tracebacks, then validation and messages, then hygiene.blocked | needs_work | readyBefore shipping: where does every error end up, is bad input refused early, is one bad item handled, is every resource released when something raises, and what should change first.
"harden"Writes the hardened version of the pasted code: the whole of every Python file it changes (and at most one new module, for example for the exception classes), with specific exceptions that say what failed, chains kept with raise ... from err, validation at the boundary, batches that keep going and make every failure visible, and every resource in a with block or released in a finally — plus an exceptions catalogue of every exception class in the code, a changes list of every change a caller will notice, and what it left open. The success path stays the same: same functions, arguments in the same order, same return values.hardened | partialWhen you want the code changed, not a list of findings: a module whose failure paths were never designed, or what is left after an audit.

The page chains the two: after an audit, “Apply every edit and harden the rest” puts every audit edit into the paste, switches to the harden lane on that edited paste and adds an audit_handoff string (below) with the audit’s verdict, headline and findings. Over the API that is a second run with task: "harden", the edited paste as code, facts recomputed from the edited paste, 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 with no Python it can read (“No Python file was found. Paste .py source, with a # file: <path>.py line above each file.”, or “This does not read as Python source: no def, class, import or call was found.”), it warns before a run on a paste with no function (“No function was found; the run will see module-level code only.”), and before a harden run on code with no try, raise or resource (“This code has no try, raise or resource yet - the harden lane will add the error handling it needs.”).

Input fields

This is exactly what the page sends (errkit.js buildInput(lane, A, note, handoff), checked by mustBeObject; app.js run), and what input-schema.json declares. The platform’s input schema is scalar-only, so every value is a string. buildInput emits them in the order task, code, facts, note, audit_handoff:

FieldTypeRequiredMeaning
taskstringyes"audit" or "harden" (the lanes above).
codestringyesThe pasted Python, each file introduced by a # file: <path> line; 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 # [pyerr-desk: not sent: ...] line at the end (below).
factsstringyesThe page’s free check, Errkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it.
notestringnoWhat the engineer says: the goal, a constraint (“callers only catch ImportFailed”, “we cannot add dependencies”), 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.
audit_handoffstringnoharden lane only: a JSON string built from an audit reply on this code (recon.js hardenHandoff, below). buildInput drops it for the audit lane.
retry_notestringnoSent 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> (a path with a . or / in it) starts each file, and a --- line just before it only separates files. Files dropped on the page (or a dropped package folder) are joined that way by Errkit.joinFiles: each file under its # file: line, trailing whitespace removed, joined by newlines, ending in a newline. A paste with no # file: line is read as one file, app.py. The page normalises line endings to \n. A .py/.pyi path is checked as Python; requirements*.txt, pyproject.toml, setup.cfg, Pipfile and poetry.lock are read as dependency files; anything else is other. 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 non-Python file 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:

# [pyerr-desk: not sent: pkg/b.py, pkg/c.py]

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. Both worked examples below are small, so their code is the paste unchanged.

The facts string

facts is optional to compute but required to send. It is produced by the page’s free check: Errkit.analyze splits the paste into files, lexes and inventories them and runs the rules, and Errkit.facts(A) turns that analysis into the object below (it does not depend on the lane: an audit and a harden run of the same paste carry the same facts). It is how the model knows which flags to answer, which file paths and line ranges to name, and which functions and exception classes must survive. Its top-level keys, in the order built:

KeyContents
moduleCounts: python_files, functions, except_blocks, exception_classes, resources.
filesEvery file: path (use it verbatim), kind (python, deps, other), line and end_line (the paste lines the file occupies, without its # file: line).
functionsEvery def (up to 200): name (Class.method for methods), file, line, params (without self/cls). The harden lane must keep every one, with its arguments in order.
exception_classesEvery exception class defined in the paste: name, base, file, line.
exceptsEvery except block (up to 150): file, line, type ((bare) for a bare except), broad, raises, logs, traceback (the traceback is kept), in_loop, does (re-raises, logs, logs, returns a value, records, ignores, returns a value, handles).
raisesEvery raise (up to 150): file, line, type ((re-raise) for a bare raise), chained, in_except.
resourcesEvery file, connection, socket, session, client, pool, temporary file or process that is opened (up to 120): file, line, call, kind, var, managed (with, finally, returned, owned, module, happy = closed only on the success path, none = never closed).
flagsEvery flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (PE-01..PE-21), sev (high | medium | low | info), file, line, text, detail, and lines only on a flag that aggregates several lines (every line it covers). An audit must answer every non-info flag by its uid.
verdict_candidateThe audit verdict the flags point at (the page’s own reading): blocked with any high flag, needs_work with any medium, otherwise ready.
notesPlain sentences about what was not read or not sent (empty for a paste that fits).

The simplest source is errkit.js itself: the page loads it from /errkit.js, and it also runs in Node (it sets module.exports). This script prints what the page would send as code and facts (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 statements-paste.txt and redirect the output into statements-check.json:

// build-check.js -- run: node build-check.js statements-paste.txt > statements-check.json   (errkit.js saved next to it)
const fs = require("fs");
const Errkit = require("./errkit.js");
const A = Errkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Errkit.buildInput("audit", A, "", null);          // code and facts do not depend on the lane
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
console.error(A.files.length + " file(s), " + A.functions.length + " function(s); " + real.length + " flags (" +
  A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);

For the statements example it reports 1 file(s), 5 function(s); 5 flags (0 high, 3 medium, 2 low); verdict_candidate needs_work; for the digest example, 1 file(s), 3 function(s); 5 flags (0 high, 4 medium, 1 low); verdict_candidate needs_work. For both worked examples its code and facts are exactly what the page sends.

Without the page’s check. The platform only checks that facts is a string, so a minimal object works — "{}", or a hand-built one such as {"flags": [], "notes": ["No page check was run; read the code directly."]} — and the model still reads code. But you lose what the check adds: with no flags there is nothing to confirm or dismiss, with no files there are no paths and line ranges to name, and with no functions the harden lane has no list of functions and arguments it must keep. Build it with errkit.js as above when you can.

The audit_handoff string

Only for task: "harden", 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 hardenHandoff:

const run = Recon.applyFindings(A, res.findings, Errkit);   // every finding's edits, in order, each on top of the last
JSON.stringify({ 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

That is {"verdict", "headline", "findings": [{id, severity, file, line, title, why, edits}], "applied", "failed"}, where each finding’s edits is the number of edits it carried. applyFindings applies every finding’s edits with Errkit.applyEdits, in the order listed: each before must occur exactly once in its file as the earlier edits left it (leading and trailing blank lines ignored), or that edit is reported as failed and skipped. It is a previous reply, not evidence: the model checks it against code and hardens what is left. This script does the whole step — the edited paste, its recomputed 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 statements-paste.txt statements-reply.txt > statements-harden-check.json   (errkit.js, recon.js next to it)
const fs = require("fs");
const Errkit = require("./errkit.js");
const Recon = require("./recon.js");
const A = Errkit.analyze(fs.readFileSync(process.argv[2], "utf8"));             // the audited paste
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), "audit");
const ho = Recon.hardenHandoff(A, res);                                         // every edit, in order
const B = Errkit.analyze(ho.text);                                              // facts recomputed from the edited paste
const inp = Errkit.buildInput("harden", B, "", ho.handoff);
console.log(JSON.stringify({ 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 statements-reply.txt; for the statements example it reports 6 edit(s) applied, 0 did not apply.

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 both lanes it must ground everything in code: a caller, a retry, a scheduler, a framework error handler, a logging configuration or a database setting that is not in the paste is never claimed to exist — it goes into questions — and an imported function’s behaviour is unknown unless the paste shows it (“if render_statement raises ...”, never “render_statement raises ...”). It must never write a secret value: a line that holds a literal credential is never copied into the reply (not in evidence, not in an edit, not in a file), and no credential, real hostname or real endpoint is invented. It keeps what the code does on the success path: same functions, names, arguments and return values; what happens on failure may change, and it says what a caller will now see. A new try/except catches specific exceptions, or — at a boundary that must keep going — catches Exception, logs it with the traceback and records it; any other new except ends with a bare raise. It uses only the standard library and what the code already imports. A bug that is not about error handling, validation, partial failure or resources goes into questions, not into a finding. In the audit lane every page flag whose sev is not info is answered exactly once in flag_answers, and an info flag is never reported as a finding.

task: "audit"

task: "harden"

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, unless it names the other lane and carries that lane’s body (a files array for harden, a findings array for audit). An unknown verdict shows as needs_work or partial and counts as a disagreement; an unknown severity becomes medium; ids like PE7 or pe 7 become PE-07; flags keeps only F<n> values; findings with no file, evidence and title are dropped; file may also arrive as path, and an open item as path; questions is capped at 10 and plan at 30. An audit reply carries 9 sections (verdict, headline, module, areas, findings, flag_answers, plan, questions, summary) and a harden reply 8 (verdict, headline, files, exceptions, changes, open, questions, summary), which is what the page counts when it recovers a partial reply.

Worked examples

Both are examples the page ships (the example buttons on the app page; the full pastes are in /example.js, ids statements and digest), built exactly as the page builds them: Errkit.buildInput(lane, Errkit.analyze(text), note), with a real model reply for each. The pastes, their facts and the audit reply are shown whole; the request bodies and the hardened file inside the harden reply are trimmed for display, and each trim is marked. What is shown is verbatim. Neither paste holds a credential (the FTP password reaches the code as a function argument).

Audit: a statement exporter whose branch archive silently never arrives

One file, statements/export.py: it renders every account’s statement into a per-branch ZIP archive with zipfile and uploads it over FTPS with ftplib through a @contextmanager, with one custom exception (StatementError) and one broad except at the top of upload(). The page’s free check raises 5 flags (3 medium, 2 low) (plus the PE-21 context flag); its own reading of the verdict is needs_work. The engineer’s note: “Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?”

The code string, whole: 1,762 characters, 63 lines ending in a newline. Save it as statements-paste.txt and run node build-check.js statements-paste.txt > statements-check.json:

# file: statements/export.py
"""Builds the monthly statement archive for Alderbrook Credit Union branches and uploads it."""
import ftplib
import logging
import os
import tempfile
import zipfile
from contextlib import contextmanager

from statements.render import TemplateMissing, render_statement

logger = logging.getLogger(__name__)

FTP_HOST = "drop.alderbrook.example"


class StatementError(Exception):
    """Raised when a statement cannot be produced."""


@contextmanager
def ftp_session(user, password):
    ftp = ftplib.FTP_TLS(FTP_HOST, timeout=30)
    ftp.login(user, password)
    ftp.prot_p()
    yield ftp
    ftp.quit()


def build_statement(account):
    try:
        return render_statement(account["id"], account["template"])
    except TemplateMissing:
        raise StatementError(f"no template {account['template']!r} for account {account['id']}")


def summary_lines(accounts):
    text = ""
    for account in accounts:
        text += f"{account['id']},{account['balance']}\n"
    return text


def build_archive(branch, accounts):
    path = os.path.join(tempfile.gettempdir(), f"{branch}-statements.zip")
    archive = zipfile.ZipFile(path, "w")
    for account in accounts:
        archive.writestr(f"{account['id']}.pdf", build_statement(account))
    archive.writestr("summary.csv", summary_lines(accounts))
    archive.close()
    return path


def upload(branch, accounts, user, password):
    try:
        path = build_archive(branch, accounts)
        with ftp_session(user, password) as ftp:
            with open(path, "rb") as fh:
                ftp.storbinary(f"STOR {branch}.zip", fh)
    except Exception:
        logger.exception("statement upload failed", extra={"branch": branch})
        return None
    return path

The facts object, decoded and whole. The string the page sends is its compact JSON.stringify, 3,892 characters:

{
  "module": {
    "python_files": 1,
    "functions": 5,
    "except_blocks": 2,
    "exception_classes": 1,
    "resources": 3
  },
  "files": [
    {
      "path": "statements/export.py",
      "kind": "python",
      "line": 2,
      "end_line": 64
    }
  ],
  "functions": [
    {
      "name": "ftp_session",
      "file": "statements/export.py",
      "line": 22,
      "params": [
        "user",
        "password"
      ]
    },
    {
      "name": "build_statement",
      "file": "statements/export.py",
      "line": 30,
      "params": [
        "account"
      ]
    },
    {
      "name": "summary_lines",
      "file": "statements/export.py",
      "line": 37,
      "params": [
        "accounts"
      ]
    },
    {
      "name": "build_archive",
      "file": "statements/export.py",
      "line": 44,
      "params": [
        "branch",
        "accounts"
      ]
    },
    {
      "name": "upload",
      "file": "statements/export.py",
      "line": 54,
      "params": [
        "branch",
        "accounts",
        "user",
        "password"
      ]
    }
  ],
  "exception_classes": [
    {
      "name": "StatementError",
      "base": "Exception",
      "file": "statements/export.py",
      "line": 17
    }
  ],
  "excepts": [
    {
      "file": "statements/export.py",
      "line": 33,
      "type": "TemplateMissing",
      "broad": false,
      "raises": true,
      "logs": false,
      "traceback": false,
      "in_loop": false,
      "does": "re-raises"
    },
    {
      "file": "statements/export.py",
      "line": 60,
      "type": "Exception",
      "broad": true,
      "raises": false,
      "logs": true,
      "traceback": true,
      "in_loop": false,
      "does": "logs, returns a value"
    }
  ],
  "raises": [
    {
      "file": "statements/export.py",
      "line": 34,
      "type": "StatementError",
      "chained": false,
      "in_except": true
    }
  ],
  "resources": [
    {
      "file": "statements/export.py",
      "line": 23,
      "call": "ftplib.FTP_TLS",
      "kind": "connection",
      "var": "ftp",
      "managed": "returned"
    },
    {
      "file": "statements/export.py",
      "line": 46,
      "call": "zipfile.ZipFile",
      "kind": "archive",
      "var": "archive",
      "managed": "happy"
    },
    {
      "file": "statements/export.py",
      "line": 58,
      "call": "open",
      "kind": "file",
      "var": null,
      "managed": "with"
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "PE-16",
      "sev": "medium",
      "file": "statements/export.py",
      "line": 26,
      "text": "the @contextmanager ftp_session() yields outside try/finally, so the code after the yield is skipped when the block raises",
      "detail": "An exception in the with block is re-raised at the yield; the 1 statement after it never run, so whatever was acquired before the yield is never released. Wrap the yield: try: yield ... finally: <cleanup>."
    },
    {
      "uid": "F2",
      "id": "PE-14",
      "sev": "medium",
      "file": "statements/export.py",
      "line": 50,
      "text": "archive.close() runs only if nothing before it raises",
      "detail": "2 statements between zipfile.ZipFile(...) at line 46 and the close() can raise, and then the archive stays open. Use with zipfile.ZipFile(...) as archive: (or close it in a finally)."
    },
    {
      "uid": "F3",
      "id": "PE-06",
      "sev": "medium",
      "file": "statements/export.py",
      "line": 60,
      "text": "except Exception turns every failure into `return None`",
      "detail": "The caller gets an ordinary-looking value for a failure and cannot tell \"not found\" from \"the database is down\"; the error surfaces later, somewhere else, as a confusing None. Raise a specific exception (chained with from) or return an explicit result type."
    },
    {
      "uid": "F4",
      "id": "PE-07",
      "sev": "low",
      "file": "statements/export.py",
      "line": 34,
      "text": "raise StatementError(...) inside an except drops the explicit chain",
      "detail": "Without `from err` the original error is attached only as implicit context (\"During handling of the above exception, another exception occurred\"), which reads as a second crash. Write raise StatementError(...) from err to say one caused the other, or from None to hide it deliberately."
    },
    {
      "uid": "F5",
      "id": "PE-19",
      "sev": "low",
      "file": "statements/export.py",
      "line": 40,
      "text": "text += ... builds a string in a loop",
      "detail": "Each += on a str copies everything accumulated so far, so building a large result this way is quadratic. Append the pieces to a list and \"\".join() them once."
    },
    {
      "uid": "F6",
      "id": "PE-21",
      "sev": "info",
      "file": "statements/export.py",
      "line": 2,
      "text": "context: 1 Python file, 5 function(s), 2 except block(s), 3 resource acquisition(s) inside functions",
      "detail": "Only what is pasted is checked: how callers handle these errors, and what a framework does with an unhandled one, is outside the check."
    }
  ],
  "verdict_candidate": "needs_work",
  "notes": []
}

The request body the page sends, trimmed for display: the full body is 6,354 characters of compact JSON, and the two long strings are cut here after their first 160 characters, with the cut marked inside the string. The step 4 samples build the untrimmed body from statements-check.json.

{
  "task": "audit",
  "code": "# file: statements/export.py\n\"\"\"Builds the monthly statement archive for Alderbrook Credit Union branches and uploads it.\"\"\"\nimport ftplib\nimport logging\nimport ... [trimmed for display: 160 of 1,762 characters shown]",
  "facts": "{\"module\":{\"python_files\":1,\"functions\":5,\"except_blocks\":2,\"exception_classes\":1,\"resources\":3},\"files\":[{\"path\":\"statements/export.py\",\"kind\":\"python\",\"line\": ... [trimmed for display: 160 of 3,892 characters shown]",
  "note": "Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?"
}

The page sends it with Idempotency-Key: pyerr-desk:audit:8d578244-18d2:a1 (see step 5). The reply, whole (the model’s output.output, parsed and indented here):

{
  "task": "audit",
  "verdict": "needs_work",
  "headline": "The FTP-upload boundary swallows every failure into `return None`, so one account with a missing template quietly cancels the whole branch's monthly archive, and both the FTP session and the ZIP archive leak whenever an error interrupts them.",
  "module": "export.py renders every account's statement into a per-branch ZIP and ships it to Alderbrook's drop server over FTPS; today any failure anywhere in that pipeline - a missing template, a bad login, a dropped connection - is caught once at the very top, logged, and turned into `return None`, so a branch's archive can fail to arrive with nothing but one log line to explain why, and the FTP connection or the ZIP file is left open when that happens.",
  "areas": [
    {
      "area": "exceptions",
      "status": "partial",
      "note": "build_statement narrows its catch and translates it into a domain exception (only missing `from err`), but upload()'s boundary catch turns every failure - including that domain exception - into an indistinguishable `return None`."
    },
    {
      "area": "validation",
      "status": "poor",
      "note": "no input is validated anywhere: account fields, branch and credentials are used unchecked, so a malformed record would surface later as a raw KeyError instead of a clear error naming the bad field."
    },
    {
      "area": "partial_failure",
      "status": "poor",
      "note": "build_archive's loop over accounts has no per-item handling: the first account whose template is missing aborts the whole archive instead of being recorded so the rest can still be built."
    },
    {
      "area": "resources",
      "status": "poor",
      "note": "two of the three acquired resources (the FTP session and the ZIP archive) are released only when nothing raises; only `open(path, \"rb\")` is closed unconditionally."
    }
  ],
  "findings": [
    {
      "id": "other",
      "flags": [],
      "file": "statements/export.py",
      "line": 47,
      "severity": "medium",
      "area": "partial_failure",
      "title": "One account's render failure aborts the whole branch archive unnoticed",
      "evidence": "    for account in accounts:\n        archive.writestr(f\"{account['id']}.pdf\", build_statement(account))",
      "why": "build_statement re-raises StatementError the instant one account's template is missing, and nothing in this loop catches it, so the first bad account stops every later account from being rendered too. That StatementError propagates straight into upload()'s broad except (line 60), which today just logs it and returns None, so a branch with 500 good accounts and 1 missing template produces no archive at all - and the only trace is one log line naming a single account. This is the mechanism behind 'a branch archive never arrives and nobody knows why' from the note. After this edit, build_archive keeps rendering every account, logs and records each failure with its account id, and - only if any failed - raises one StatementError naming all of them after the loop; a caller that used to get a silent abort on account #1 now gets a complete report of every bad account in one run.",
      "edits": [
        {
          "before": "    for account in accounts:\n        archive.writestr(f\"{account['id']}.pdf\", build_statement(account))",
          "after": "    failures = []\n    for account in accounts:\n        try:\n            archive.writestr(f\"{account['id']}.pdf\", build_statement(account))\n        except StatementError as exc:\n            logger.exception(\n                \"statement render failed\",\n                extra={\"branch\": branch, \"account\": account.get(\"id\")},\n            )\n            failures.append((account.get(\"id\"), exc))\n    if failures:\n        failed_ids = \", \".join(str(account_id) for account_id, _ in failures)\n        raise StatementError(\n            f\"{len(failures)} of {len(accounts)} statements failed to render \"\n            f\"for branch {branch!r}: {failed_ids}\"\n        ) from failures[0][1]"
        }
      ]
    },
    {
      "id": "PE-06",
      "flags": [
        "F3"
      ],
      "file": "statements/export.py",
      "line": 60,
      "severity": "medium",
      "area": "exceptions",
      "title": "upload() turns every failure into `return None`",
      "evidence": "    except Exception:\n        logger.exception(\"statement upload failed\", extra={\"branch\": branch})\n        return None",
      "why": "Whatever failed - a missing template, a bad FTP login, a dropped connection - is logged with its traceback and then thrown away as None. A caller that checks the return value cannot tell 'nothing to upload' from 'it broke', and one that ignores the return value never learns anything failed at all; either way the branch's statements silently never leave. After this edit, upload() still logs with the traceback first, then re-raises the original exception instead of returning None, so a caller now sees the failure instead of a routine-looking empty result.",
      "edits": [
        {
          "before": "    except Exception:\n        logger.exception(\"statement upload failed\", extra={\"branch\": branch})\n        return None",
          "after": "    except Exception:\n        logger.exception(\"statement upload failed\", extra={\"branch\": branch})\n        raise"
        }
      ]
    },
    {
      "id": "PE-16",
      "flags": [
        "F1"
      ],
      "file": "statements/export.py",
      "line": 26,
      "severity": "medium",
      "area": "resources",
      "title": "ftp_session's cleanup is skipped when the block raises",
      "evidence": "    yield ftp\n    ftp.quit()",
      "why": "ftp.quit() sits after the yield with no try/finally, so when the code inside `with ftp_session(...)` raises (a storbinary failure, for example), the exception resumes the generator at the yield and propagates out immediately - ftp.quit() never runs, and the FTPS connection to drop.alderbrook.example is left open until it times out on its own instead of being released. After this edit the yield is wrapped in try/finally, so ftp.quit() runs whether the block succeeds or raises; the return value and behavior on success are unchanged.",
      "edits": [
        {
          "before": "    ftp.prot_p()\n    yield ftp\n    ftp.quit()",
          "after": "    ftp.prot_p()\n    try:\n        yield ftp\n    finally:\n        ftp.quit()"
        }
      ]
    },
    {
      "id": "PE-14",
      "flags": [
        "F2"
      ],
      "file": "statements/export.py",
      "line": 50,
      "severity": "medium",
      "area": "resources",
      "title": "the ZIP archive is closed only when nothing raises",
      "evidence": "    archive.writestr(\"summary.csv\", summary_lines(accounts))\n    archive.close()",
      "why": "archive.close() only runs after every writestr before it succeeds; if a later step raises (including the StatementError raised by the finding above when accounts fail), build_archive exits without closing the ZipFile, leaving the file handle - and the half-written .zip - open. After this edit the archive is opened with a `with zipfile.ZipFile(...) as archive:` block, so it is always finalized and closed on the way out, success or failure; build_archive still returns the same path on success.",
      "edits": [
        {
          "before": "    archive = zipfile.ZipFile(path, \"w\")\n    failures = []\n    for account in accounts:\n        try:\n            archive.writestr(f\"{account['id']}.pdf\", build_statement(account))\n        except StatementError as exc:\n            logger.exception(\n                \"statement render failed\",\n                extra={\"branch\": branch, \"account\": account.get(\"id\")},\n            )\n            failures.append((account.get(\"id\"), exc))\n    if failures:\n        failed_ids = \", \".join(str(account_id) for account_id, _ in failures)\n        raise StatementError(\n            f\"{len(failures)} of {len(accounts)} statements failed to render \"\n            f\"for branch {branch!r}: {failed_ids}\"\n        ) from failures[0][1]\n    archive.writestr(\"summary.csv\", summary_lines(accounts))\n    archive.close()",
          "after": "    with zipfile.ZipFile(path, \"w\") as archive:\n        failures = []\n        for account in accounts:\n            try:\n                archive.writestr(f\"{account['id']}.pdf\", build_statement(account))\n            except StatementError as exc:\n                logger.exception(\n                    \"statement render failed\",\n                    extra={\"branch\": branch, \"account\": account.get(\"id\")},\n                )\n                failures.append((account.get(\"id\"), exc))\n        if failures:\n            failed_ids = \", \".join(str(account_id) for account_id, _ in failures)\n            raise StatementError(\n                f\"{len(failures)} of {len(accounts)} statements failed to render \"\n                f\"for branch {branch!r}: {failed_ids}\"\n            ) from failures[0][1]\n        archive.writestr(\"summary.csv\", summary_lines(accounts))"
        }
      ]
    },
    {
      "id": "PE-07",
      "flags": [
        "F4"
      ],
      "file": "statements/export.py",
      "line": 34,
      "severity": "low",
      "area": "exceptions",
      "title": "StatementError drops the original exception's chain",
      "evidence": "    except TemplateMissing:\n        raise StatementError(f\"no template {account['template']!r} for account {account['id']}\")",
      "why": "Without `from err`, the traceback shows TemplateMissing only as implicit context ('During handling of the above exception...'), which reads like a second, unrelated crash rather than the actual cause. After this edit the chain is explicit, so a log or debugger shows the original TemplateMissing as the direct cause of the StatementError; the message and StatementError type raised are unchanged.",
      "edits": [
        {
          "before": "    except TemplateMissing:\n        raise StatementError(f\"no template {account['template']!r} for account {account['id']}\")",
          "after": "    except TemplateMissing as err:\n        raise StatementError(f\"no template {account['template']!r} for account {account['id']}\") from err"
        }
      ]
    },
    {
      "id": "PE-19",
      "flags": [
        "F5"
      ],
      "file": "statements/export.py",
      "line": 40,
      "severity": "low",
      "area": "resources",
      "title": "summary_lines builds its string with +=",
      "evidence": "    text = \"\"\n    for account in accounts:\n        text += f\"{account['id']},{account['balance']}\\n\"",
      "why": "Each += copies everything accumulated so far, so summary_lines is quadratic in the number of accounts - needless CPU for a large branch on every run. After this edit it collects the lines in a list and joins once; the returned string is byte-for-byte identical.",
      "edits": [
        {
          "before": "    text = \"\"\n    for account in accounts:\n        text += f\"{account['id']},{account['balance']}\\n\"\n    return text",
          "after": "    lines = []\n    for account in accounts:\n        lines.append(f\"{account['id']},{account['balance']}\\n\")\n    return \"\".join(lines)"
        }
      ]
    },
    {
      "id": "PE-11",
      "flags": [],
      "file": "statements/export.py",
      "line": 55,
      "severity": "low",
      "area": "exceptions",
      "title": "upload()'s try block spans two unrelated failure modes",
      "evidence": "    try:\n        path = build_archive(branch, accounts)",
      "why": "One try wraps both building the archive (rendering every statement) and the whole FTP upload; both land on the same 'statement upload failed' log line, so distinguishing a rendering problem from a login or network problem means reading the traceback that follows rather than the log line itself. This is a hygiene note, not a behavior bug: once the finding above stops the boundary from hiding the traceback, the information needed to tell the two apart is present, just not summarized in the log message. No edit is proposed here because narrowing the try would mean restructuring upload() around a judgment call (whether a render failure and an upload failure should ever be handled differently) that the paste does not answer.",
      "edits": []
    }
  ],
  "flag_answers": [
    {
      "flag": "F1",
      "status": "confirmed",
      "reason": "ftp_session yields at line 26 with no try/finally; ftp.quit() at line 27 is skipped whenever the with-block raises - fixed by wrapping the yield in try/finally."
    },
    {
      "flag": "F2",
      "status": "confirmed",
      "reason": "archive.close() at line 50 only runs if every writestr before it succeeds - fixed by opening the ZipFile with a with block."
    },
    {
      "flag": "F3",
      "status": "confirmed",
      "reason": "the except Exception at line 60 logs with a traceback but returns None on every failure - fixed to re-raise after logging."
    },
    {
      "flag": "F4",
      "status": "confirmed",
      "reason": "the raise at line 34 has no from, so the TemplateMissing chain is only implicit - fixed with from err."
    },
    {
      "flag": "F5",
      "status": "confirmed",
      "reason": "text += ... at line 40 builds the CSV body in a loop - fixed with a list and join."
    }
  ],
  "plan": [
    "Make build_archive keep rendering every account when one fails, record each failure, and raise one StatementError naming every failed account after the loop, instead of letting the first bad template abort the whole run unnoticed.",
    "Make upload() re-raise after logging instead of returning None, so a failed branch is never indistinguishable from an empty successful one.",
    "Wrap ftp_session's yield in try/finally so the FTPS connection is always closed.",
    "Open the ZIP archive with a with block so it is always closed, even when a statement fails to render.",
    "Add `from err` to the StatementError raised in build_statement.",
    "Build summary_lines with a list and str.join instead of +=.",
    "Optionally split upload()'s try block or tag its log line so a render failure and an FTP failure read differently in the logs - a nice-to-have, not required once the traceback is restored."
  ],
  "questions": [
    "Is upload() called from an outer loop over all branches? Re-raising here (finding on line 60) means a failing branch now stops that loop unless it already wraps each branch in its own try/except - please confirm that's the intended behavior outside this paste.",
    "Are account dicts (id, template, balance) already validated before reaching this module, or should build_statement/summary_lines check their shape and raise a clear ValueError instead of letting a malformed record surface as a bare KeyError?",
    "ftp_session's new finally calls ftp.quit() unconditionally; if the connection is already broken when the with-block raises, quit() can itself raise and replace the original exception - is a best-effort quit (catching and logging just that secondary error) wanted, or should the original failure always win?",
    "build_archive writes {branch}-statements.zip to the shared system temp directory under a predictable name, and nothing in this paste ever deletes it, success or failure - is cleanup handled by something outside this module, or should upload() remove it after the STOR (or the attempt)?"
  ],
  "summary": "export.py's only failure boundary is upload()'s broad except, which logs with a traceback but then returns None - so today a single account with a missing template aborts build_archive's loop, and that error is swallowed into an ordinary-looking empty result, which is why a branch's archive can silently never arrive some months. The findings make every account's render failure visible and named instead of aborting at the first one, stop the boundary from hiding failures behind None, and close the FTP connection and ZIP archive on every path, not just the happy one. Two low-severity hygiene items (a missing exception chain and a += string build) round out the change. Nothing on the success path changes: same functions, arguments and return values throughout."
}

Held to the page’s own reconciliation (recon.js reconcile, on this exact body and reply), this reply has 0 disagreements in 8 statements checked: the four areas are rated once each, every one of the 7 findings names a pasted file, a line inside it and evidence copied from it, the verdict fits the worst finding, all 5 flags are answered once, and all 6 edits apply in order with each before found exactly once. With every edit applied, every flag a finding with edits carries is gone and all 5 functions are still there. The one finding with no edit (PE-11, a wide try in upload()) is low, as the contract allows. The recheck.js script under The page checks the reply against the code runs that reconciliation for you.

What the step 5 samples print for this reply (the Python form; charged is whatever the run cost):

truncated: False charged: ...
needs_work - The FTP-upload boundary swallows every failure into `return None`, so one account with a missing template quietly cancels the whole branch's monthly archive, and both the FTP session and the ZIP archive leak whenever an error interrupts them.
area exceptions partial
area validation poor
area partial_failure poor
area resources poor
other statements/export.py 47 medium 1 edit(s)
PE-06 statements/export.py 60 medium 1 edit(s)
PE-16 statements/export.py 26 medium 1 edit(s)
PE-14 statements/export.py 50 medium 1 edit(s)
PE-07 statements/export.py 34 low 1 edit(s)
PE-19 statements/export.py 40 low 1 edit(s)
PE-11 statements/export.py 55 low 0 edit(s)
answer F1 confirmed
answer F2 confirmed
answer F3 confirmed
answer F4 confirmed
answer F5 confirmed

Had you pressed “Apply every edit and harden the rest” after this reply, the page would put the 6 edits into the paste, recompute facts from the edited paste, and send the harden lane (with the same note) with this audit_handoff — what handoff.js prints as audit_handoff — shown decoded and trimmed to 2 of its 7 findings; in the body it is the compact JSON.stringify of it (5,268 characters). That body is 10,827 characters and its key would be pyerr-desk:harden:d896d985-2a4b:a1:

{
  "verdict": "needs_work",
  "headline": "The FTP-upload boundary swallows every failure into `return None`, so one account with a missing template quietly cancels the whole branch's monthly archive, and both the FTP session and the ZIP archive leak whenever an error interrupts them.",
  "findings": [
    {
      "id": "other",
      "severity": "medium",
      "file": "statements/export.py",
      "line": 47,
      "title": "One account's render failure aborts the whole branch archive unnoticed",
      "why": "build_statement re-raises StatementError the instant one account's template is missing, and nothing in this loop catches it, so the first bad account stops every later account from being rendered too. That StatementError propagates straight into upload()'s broad except (line 60), which today just logs it and returns None, so a branch with 500 good accounts and 1 missing template produces no archive at all - and the only trace is one log line naming a single account. This is the mechanism behind 'a branch archive never arrives and nobody knows why' from the note. After this edit, build_archive keeps rendering every account, logs and records each failure with its account id, and - only if any failed - raises one StatementError naming all of them after the loop; a caller that used to get a silent abort on account #1 now gets a complete report of every bad account in one run.",
      "edits": 1
    },
    {
      "id": "PE-06",
      "severity": "medium",
      "file": "statements/export.py",
      "line": 60,
      "title": "upload() turns every failure into `return None`",
      "why": "Whatever failed - a missing template, a bad FTP login, a dropped connection - is logged with its traceback and then thrown away as None. A caller that checks the return value cannot tell 'nothing to upload' from 'it broke', and one that ignores the return value never learns anything failed at all; either way the branch's statements silently never leave. After this edit, upload() still logs with the traceback first, then re-raises the original exception instead of returning None, so a caller now sees the failure instead of a routine-looking empty result.",
      "edits": 1
    },
    ... [trimmed for display: 5 more findings, same shape]
  ],
  "applied": 6,
  "failed": 0
}

Harden: a mail digest sender that ignores refused recipients

One file, digest/sender.py: it loads library members from a JSON file (opened inline and never closed) and sends each one a digest over smtplib.SMTP in a loop, where a refused recipient is passed over silently, any other error is logged without its traceback, and server.quit() runs only when nothing raised. The free check raises 5 flags (4 medium, 1 low); its reading of the verdict is needs_work. This example has no audit before it, so it carries no audit_handoff. The engineer’s note: “Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error.”

The code string, whole: 1,181 characters, 44 lines ending in a newline. Save it as digest-paste.txt and run node build-check.js digest-paste.txt > digest-check.json:

# file: digest/sender.py
"""Sends the weekly reading digest to Wrenmoor Library members."""
import json
import logging
import smtplib
from email.message import EmailMessage

logger = logging.getLogger(__name__)

SMTP_HOST = "smtp.wrenmoor-library.example"


def load_members(path):
    data = json.load(open(path))
    return [m for m in data if m.get("digest")]


def build_message(member, books):
    msg = EmailMessage()
    msg["To"] = member["email"]
    msg["From"] = "digest@wrenmoor-library.example"
    msg["Subject"] = "Your weekly reading digest"
    msg["X-Member-Id"] = str(member["id"])
    body = ""
    for book in books:
        body += f"- {book['title']} by {book['author']}\n"
    msg.set_content(body)
    return msg


def send_digest(members_path, books):
    server = smtplib.SMTP(SMTP_HOST, 587, timeout=20)
    server.starttls()
    sent = 0
    for member in load_members(members_path):
        try:
            server.send_message(build_message(member, books))
            sent += 1
        except smtplib.SMTPRecipientsRefused:
            pass
        except Exception as e:
            logger.error("digest failed: %s", e)
    server.quit()
    return sent

The facts object, decoded and whole (3,190 characters as the page’s string):

{
  "module": {
    "python_files": 1,
    "functions": 3,
    "except_blocks": 2,
    "exception_classes": 0,
    "resources": 2
  },
  "files": [
    {
      "path": "digest/sender.py",
      "kind": "python",
      "line": 2,
      "end_line": 45
    }
  ],
  "functions": [
    {
      "name": "load_members",
      "file": "digest/sender.py",
      "line": 13,
      "params": [
        "path"
      ]
    },
    {
      "name": "build_message",
      "file": "digest/sender.py",
      "line": 18,
      "params": [
        "member",
        "books"
      ]
    },
    {
      "name": "send_digest",
      "file": "digest/sender.py",
      "line": 31,
      "params": [
        "members_path",
        "books"
      ]
    }
  ],
  "exception_classes": [],
  "excepts": [
    {
      "file": "digest/sender.py",
      "line": 39,
      "type": "smtplib.SMTPRecipientsRefused",
      "broad": false,
      "raises": false,
      "logs": false,
      "traceback": false,
      "in_loop": true,
      "does": "ignores"
    },
    {
      "file": "digest/sender.py",
      "line": 41,
      "type": "Exception",
      "broad": true,
      "raises": false,
      "logs": true,
      "traceback": false,
      "in_loop": true,
      "does": "logs"
    }
  ],
  "raises": [],
  "resources": [
    {
      "file": "digest/sender.py",
      "line": 14,
      "call": "open",
      "kind": "file",
      "var": null,
      "managed": "none"
    },
    {
      "file": "digest/sender.py",
      "line": 32,
      "call": "smtplib.SMTP",
      "kind": "connection",
      "var": "server",
      "managed": "happy"
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "PE-13",
      "sev": "medium",
      "file": "digest/sender.py",
      "line": 14,
      "text": "open(...) is opened inline and never closed",
      "detail": "The file is released only when the garbage collector gets to it - at once in CPython on the happy path, much later on PyPy or when an exception holds a reference to the frame - so descriptors and connections leak under errors and load. Use with open(...) as ...:."
    },
    {
      "uid": "F2",
      "id": "PE-05",
      "sev": "medium",
      "file": "digest/sender.py",
      "line": 39,
      "text": "except smtplib.SMTPRecipientsRefused inside a loop skips the item without recording it",
      "detail": "The batch silently loses the items that raise smtplib.SMTPRecipientsRefused: the caller cannot tell how many were skipped or which. Count or collect the skipped items and report them with the result."
    },
    {
      "uid": "F3",
      "id": "PE-03",
      "sev": "medium",
      "file": "digest/sender.py",
      "line": 42,
      "text": "the broad except logs the error without its traceback and does not re-raise",
      "detail": "logger.error(...) keeps only the message; the stack trace that says where it failed is lost, so an unexpected error cannot be found from the log. Use logger.exception(...) or pass exc_info=True."
    },
    {
      "uid": "F4",
      "id": "PE-14",
      "sev": "medium",
      "file": "digest/sender.py",
      "line": 43,
      "text": "server.quit() runs only if nothing before it raises",
      "detail": "4 statements between smtplib.SMTP(...) at line 32 and the quit() can raise, and then the connection stays open. Use with smtplib.SMTP(...) as server: (or close it in a finally)."
    },
    {
      "uid": "F5",
      "id": "PE-19",
      "sev": "low",
      "file": "digest/sender.py",
      "line": 26,
      "text": "body += ... builds a string in a loop",
      "detail": "Each += on a str copies everything accumulated so far, so building a large result this way is quadratic. Append the pieces to a list and \"\".join() them once."
    },
    {
      "uid": "F6",
      "id": "PE-21",
      "sev": "info",
      "file": "digest/sender.py",
      "line": 2,
      "text": "context: 1 Python file, 3 function(s), 2 except block(s), 2 resource acquisition(s) inside functions",
      "detail": "Only what is pasted is checked: how callers handle these errors, and what a framework does with an unhandled one, is outside the check."
    }
  ],
  "verdict_candidate": "needs_work",
  "notes": []
}

The request body, trimmed for display the same way (the full body is 5,185 characters), sent with Idempotency-Key: pyerr-desk:harden:190dd873-1441:a1:

{
  "task": "harden",
  "code": "# file: digest/sender.py\n\"\"\"Sends the weekly reading digest to Wrenmoor Library members.\"\"\"\nimport json\nimport logging\nimport smtplib\nfrom email.message import Em ... [trimmed for display: 160 of 1,181 characters shown]",
  "facts": "{\"module\":{\"python_files\":1,\"functions\":3,\"except_blocks\":2,\"exception_classes\":0,\"resources\":2},\"files\":[{\"path\":\"digest/sender.py\",\"kind\":\"python\",\"line\":2,\"e ... [trimmed for display: 160 of 3,190 characters shown]",
  "note": "Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error."
}

The reply, parsed and indented, with the one file’s content cut after 200 characters (marked inside the string); every other value is whole:

{
  "task": "harden",
  "verdict": "hardened",
  "headline": "digest/sender.py now closes its file and SMTP connection on every path, counts and logs every refused or failed member by id (never by email), and still returns the number of digests sent.",
  "files": [
    {
      "path": "digest/sender.py",
      "content": "\"\"\"Sends the weekly reading digest to Wrenmoor Library members.\"\"\"\nimport json\nimport logging\nimport smtplib\nfrom email.message import EmailMessage\n\nlogger = logging.getLogger(__name__)\n\nSMTP_HOST = \"sm ... [trimmed for display: 200 of 1,612 characters shown; the whole file is below]"
    }
  ],
  "exceptions": [],
  "changes": [
    {
      "area": "resources",
      "where": "load_members",
      "what": "open(path) is now opened in a with block so the file descriptor is closed as soon as the JSON is read, on the success path and on a parse error alike."
    },
    {
      "area": "resources",
      "where": "send_digest",
      "what": "the SMTP connection is opened with `with smtplib.SMTP(...) as server:`, so it is closed (QUIT, then the socket) whether starttls, load_members, or a member's send raises, not only when nothing raises before the old server.quit(). A failure to construct the connection itself still happens before the with block, so it still propagates unchanged and fails the whole run."
    },
    {
      "area": "partial_failure",
      "where": "send_digest",
      "what": "smtplib.SMTPRecipientsRefused is now counted (refused) and logged with the member id and the refusal's SMTP response codes (exc.recipients.values(), never the email key) instead of being passed silently."
    },
    {
      "area": "exceptions",
      "where": "send_digest",
      "what": "the broad `except Exception` now uses logger.exception (keeps the traceback) keyed only by member id, and counts the failure (failed), instead of logger.error(\"digest failed: %s\", e) which dropped the traceback and could echo back address-bearing exception text."
    },
    {
      "area": "partial_failure",
      "where": "send_digest",
      "what": "added one summary log line (sent/refused/failed counts) after the loop; the return value is unchanged (still just `sent`), per the note that counts only need to reach the log."
    },
    {
      "area": "resources",
      "where": "build_message",
      "what": "the digest body is now built by appending to a list and \"\".join(...) once, instead of += on a str in the loop."
    }
  ],
  "open": [],
  "questions": [
    "books is shared across every member in one send_digest call and is not validated up front; a single malformed entry (missing 'title' or 'author') will make build_message raise for every member in the batch, so failed would equal the whole member list and sent would be 0 - each failure is still recorded and logged (nothing vanishes), but should books be validated once before the loop so a bad catalogue entry is reported as one error instead of once per member?",
    "logger.exception on the broad except keeps the full traceback as required, but if an unexpected SMTP error's own message happens to echo the recipient address back from the server, that address could still appear inside the traceback text even though the log call itself never references member['email']. Is that acceptable for this log, or should the exception text be scrubbed before logging?"
  ],
  "summary": "Both resources the module opens (the members file and the SMTP connection) are now closed on every path via with blocks, closing the two medium resource flags. Inside the batch, a refused recipient is now counted and logged by member id with its SMTP response codes (never the email), and an unexpected per-member error is logged with logger.exception so its traceback is kept; both are counted and a sent/refused/failed summary line is logged after the loop, while send_digest keeps returning just the sent count as asked. The one low finding (+= string building) was fixed too. No new dependency, exception class, or public signature was introduced."
}

The content of digest/sender.py in that reply, whole (1,612 characters):

"""Sends the weekly reading digest to Wrenmoor Library members."""
import json
import logging
import smtplib
from email.message import EmailMessage

logger = logging.getLogger(__name__)

SMTP_HOST = "smtp.wrenmoor-library.example"


def load_members(path):
    with open(path) as f:
        data = json.load(f)
    return [m for m in data if m.get("digest")]


def build_message(member, books):
    msg = EmailMessage()
    msg["To"] = member["email"]
    msg["From"] = "digest@wrenmoor-library.example"
    msg["Subject"] = "Your weekly reading digest"
    msg["X-Member-Id"] = str(member["id"])
    lines = []
    for book in books:
        lines.append(f"- {book['title']} by {book['author']}\n")
    msg.set_content("".join(lines))
    return msg


def send_digest(members_path, books):
    sent = 0
    refused = 0
    failed = 0
    with smtplib.SMTP(SMTP_HOST, 587, timeout=20) as server:
        server.starttls()
        for member in load_members(members_path):
            try:
                server.send_message(build_message(member, books))
                sent += 1
            except smtplib.SMTPRecipientsRefused as exc:
                refused += 1
                logger.error(
                    "digest refused for member %s (smtp response: %s)",
                    member.get("id"), list(exc.recipients.values()),
                )
            except Exception:
                failed += 1
                logger.exception("digest failed for member %s", member.get("id"))
    logger.info(
        "digest run complete: sent=%d refused=%d failed=%d", sent, refused, failed
    )
    return sent

Held to the page’s reconciliation, this reply has 0 disagreements in 8 statements checked: the one file comes back whole with no tabs; with it put into the paste the free check raises no high or medium flag (and no low one either); all 3 functions are still there and keep their arguments in order; the empty exceptions list matches the code (the hardened file defines no exception class); every one of the 6 changes names a function that is in the hardened code; and the verdict hardened fits the empty open list.

What the step 6 samples print for this reply (the Python form):

truncated: False charged: ...
hardened - digest/sender.py now closes its file and SMTP connection on every path, counts and logs every refused or failed member by id (never by email), and still returns the number of digests sent.
file digest/sender.py 1612 characters
change resources load_members - open(path) is now opened in a with block so the file descriptor is closed as soon as the JSON is read, on the success path and on a parse error alike.
change resources send_digest - the SMTP connection is opened with `with smtplib.SMTP(...) as server:`, so it is closed (QUIT, then the socket) whether starttls, load_members, or a member's send raises, not only when nothing raises before the old server.quit(). A failure to construct the connection itself still happens before the with block, so it still propagates unchanged and fails the whole run.
change partial_failure send_digest - smtplib.SMTPRecipientsRefused is now counted (refused) and logged with the member id and the refusal's SMTP response codes (exc.recipients.values(), never the email key) instead of being passed silently.
change exceptions send_digest - the broad `except Exception` now uses logger.exception (keeps the traceback) keyed only by member id, and counts the failure (failed), instead of logger.error("digest failed: %s", e) which dropped the traceback and could echo back address-bearing exception text.
change partial_failure send_digest - added one summary log line (sent/refused/failed counts) after the loop; the return value is unchanged (still just `sent`), per the note that counts only need to reach the log.
change resources build_message - the digest body is now built by appending to a list and "".join(...) once, instead of += on a str in the loop.
question books is shared across every member in one send_digest call and is not validated up front; a single malformed entry (missing 'title' or 'author') will make build_message raise for every member in the batch, so failed would equal the whole member list and sent would be 0 - each failure is still recorded and logged (nothing vanishes), but should books be validated once before the loop so a bad catalogue entry is reported as one error instead of once per member?
question logger.exception on the broad except keeps the full traceback as required, but if an unexpected SMTP error's own message happens to echo the recipient address back from the server, that address could still appear inside the traceback text even though the log call itself never references member['email']. Is that acceptable for this log, or should the exception text be scrubbed before logging?

The page 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 checks 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.

recon.js also runs in Node, so you can hold a reply to exactly the page’s standard. Save /errkit.js and /recon.js next to this script, pass the file you built the body from (build-check.js or handoff.js output), the reply text and the lane; it prints every statement, the count, and the paste with every edit (or file) put back:

// recheck.js -- run: node recheck.js statements-check.json statements-reply.txt audit > statements-edited.txt   (errkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js");                                            // loads ./errkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));             // code, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);  // 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

For the statements audit reply it reports:

ok: The areas table rates exceptions, validation, partial failure and resources once each.
ok: Every one of the 7 findings names a pasted file, a line inside it and evidence copied from it.
ok: The verdict (needs_work) fits the worst finding.
ok: Every one of the page's 5 flags is answered once.
ok: All 6 edits applied in order, each before text found exactly once.
ok: Every confirmed flag a finding carries is gone once all edits are applied.
ok: Every function of the paste is still there after the edits.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 8 statements checked

and for the digest harden reply (node recheck.js digest-check.json digest-reply.txt harden):

ok: 1 file returned whole, no tabs.
ok: With the hardened files in place the free check raises no high or medium flag (0 low).
ok: Every one of the 3 functions is still there.
ok: Every function keeps its arguments, in order.
ok: exceptions matches the code: 0 exception classes with their bases.
ok: Every one of the 6 changes names a function or class that is in the hardened code.
ok: The verdict (hardened) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 8 statements checked

After that, run the module’s own tests — including one that makes a single item fail and one that makes the connection fail — and read the log lines each failure leaves 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, 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}'

Step 2 — a token

Two kinds work:

# 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":"pyerr-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

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'

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, the note when it is not blank, and — for the harden lane only — the audit_handoff string when the file carries one. It reads the file build-check.js (or handoff.js) printed. The samples build the audit input of the statements example from statements-check.json; step 6 reuses makeInput for the digest (harden) 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:

FieldMeaning
hold_creditsA 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 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_creditsThe 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_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput 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.

# statements-check.json: printed by build-check.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "Some months a branch archive never arrives and nobody knows why. What happens when one statement fails to render?" \
  '{task: "audit", code, facts: (.facts | tojson), note: $note}' statements-check.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Harden lane after an audit: read handoff.js's output instead and add its string:
#   '{task: "harden", 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})'

Step 5 — POST /run, then poll the job (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:

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 an audit and a harden run of the same paste never share a key. The page uses pyerr-desk:<lane>:<inputHash>:a<attempt>, where <lane> is audit or harden, <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 two worked examples that is pyerr-desk:audit:8d578244-18d2:a1 and pyerr-desk:harden:190dd873-1441:a1. 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.

KEY="pyerr-desk:audit:$(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}]}'

Step 6 — POST /run-stream (Server-Sent Events, harden)

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 the second lane, the samples build the harden input of the digest example (with its own key) and stream it; to stream the audit 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:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated 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 entry of the exception catalogue (name, base and raised_in), each change (area, where, what), 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 harden. To harden a module you audited, read handoff.js’s output instead of digest-check.json: makeInput then carries its audit_handoff.

# the harden worked example: digest-check.json from build-check.js
# (for an audited module, read handoff.js's output instead; the + (...) adds its audit_handoff)
IBODY=$(jq -c --arg note "Harden this. Keep going when one member fails, keep returning the number of digests sent, and log every failure with the member id (never the email address). Refused recipients should be counted and logged, not ignored; the counts only need to be in the log. If the SMTP connection itself cannot be opened, the whole run should fail with that error." \
  '{task: "harden", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
  digest-check.json)
IKEY="pyerr-desk:harden:$(printf '%s' "$IBODY" | 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: $IKEY" -d "$IBODY")

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"harden\",...}"}}

# 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)}],
  exceptions: [(.exceptions // [])[] | {name, base, raised_in}],
  changes: [(.changes // [])[] | {area, where, what}], open, questions}'

What the page does that you now do

Or skip all of this and use PyErr Desk in the browser: the check, the flags, the except-block, raise, exception-class and resource inventories, putting an edit or the hardened files back into the paste, and the Markdown and CSV exports are free.