← SkillSafe / PSM Desk / API
Tokens

Drive PSM Desk from your own code

Everything the web page does is available over HTTP: send the peptide-spectrum-match export one finished LC-MS/MS run just produced — the identification table as your engine wrote it, with the search parameters and the run notes beside it — and get back the same structured review the browser renders. Three lanes share one request shape and one response envelope: ident decides whether the identifications are reportable at all, quant decides whether the intensities in the same export can carry a quantitative claim, and methods writes the methods paragraph, the reporting table and the deposition checklist you can actually defend.

The natural uses are a gate in a core facility's own pipeline — refuse to release a run whose ident lane comes back not_reportable, the way a test suite refuses a failing build — and a batch pass over a directory of saved exports that says which runs were released as 1% FDR while their own decoy counts say otherwise, before a reviewer has to find it. Neither use needs the browser, and both are three calls: mint, price, run.

PSM Desk reads one finished run. It does not search spectra, it never sees your raw or mzML files, and it cannot compute a number the export does not carry. Everything below is built on what the table and the parameters say, which is exactly why the contract is so insistent about the difference between a number this page recomputed, a number your software reported, and a quantity nothing in the input establishes at all.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... }, "meta": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }

meta is advisory — request ids, timing, occasional deprecation notes — and never carries the answer. Read ok first, then data or error, and never branch on the HTTP status alone: a 200 with ok: false is possible and an error object is always the authority on what went wrong.

There is no X-App-Slug header. The browser SDK this app ships sends exactly two headers on a normal call — Content-Type: application/json and Authorization: Bearer … — plus Idempotency-Key on a run and Accept: text/event-stream on a stream. The slug psm-desk appears in one place only: the body of POST /guest. A token is already bound to its app, so nothing downstream needs to be told the slug again, and a header that looks like it should work is simply ignored. People invent that header, spend an afternoon on it, and it was never read.

The endpoints, in the order a caller uses them:

callauthcostwhat it does
POST /guestnonefreeMints a guest token for one app. Answers 201 with {token, guest_id, expires_at}.
GET /metokenfreeReturns {subject_type, subject_id, credits} and nothing else.
POST /estimatetokenfreePrices an input. Creates no job and charges nothing — but it is authenticated, so it has to come after the token.
POST /runtokenmeteredStarts a review. Returns {job_id}.
GET /job/{job_id}tokenfreePolls one job. The terminal job carries output.output, charged_credits and truncated.
POST /run-streamtokenmeteredThe same run as server-sent events: job, delta, done, and error on a failure.

The request body IS the input object. It is never wrapped in an input key.

The body of /estimate, /run and /run-stream is the flat input object — {"task": …, "psms": …, "params": …, …}. Not {"input": {…}}, not {"body": {…}}, not {"data": {…}}. This is the single most important sentence on the page, because the wrong shape does not fail: a wrapped body returns 200, reserves a plausible-looking hold, produces a job that succeeds, and bills you — while the model receives an object with none of the fields it is told to read. What comes back is a fluent identification review of nothing: an assessment of a run whose PSM table, decoy counts and search parameters it never saw. There is no error code for it and no warning in the reply. The only defence is sending the object flat, which is what every sample on this page does, plus the one assertion in the verification step — read lane back and compare it to the task you sent.

See the request body for the field list.

Error codes

codestatuswhat causes it here, and what to do
VALIDATION_ERROR400The body is not the shape the app expects — most often a missing psms, or a prescan_facts sent as a JSON string instead of an object. A body that is not valid JSON at all lands here too. Note what is not a validation error: an absent task (the model picks a lane), an absent params (legitimate, and the commonest shape of a first paste), and a body wrapped in input (a silent 200).
UNAUTHORIZED401The token is missing, malformed or past its expires_at. The common surprise is a 401 from /estimate: it is free but still authenticated, so it cannot run before step 1. Mint another token with POST /guest, or copy a personal one from the token page.
INSUFFICIENT_CREDITS402The balance is below min_credits. Call /estimate first — it is free — and compare min_credits against credits from /me before you start a batch of runs. A balance between min_credits and hold_credits does not 402: it runs truncated. See truncation.
FORBIDDEN403The token is valid but not for this app, or a guest token tried a metered run. Mint the token against psm-desk, and sign in for a metered lane — a review is metered, so a guest gets a 403 on /run rather than a 402.
NOT_FOUND404An unknown job_id, or a slug in the /guest body that does not exist. Check for a typo in slug; psm and psm-desk are not the same app.
RATE_LIMITED429Too many requests. Back off and retry with the same Idempotency-Key; a tight retry loop with fresh keys is how a batch bills four times for one export.
INTERNAL500A server-side failure. Retry with the SAME Idempotency-Key so a half-finished run is not billed twice. If it repeats on one run and not on others, send fewer table rows or shorten params — a forty-thousand-row export pasted whole is the usual trigger, and it is also the case the row sampler exists to prevent.

The two failure modes with no error code are worth more attention than the seven above. One is the wrapped input key. The other is a reply that looks complete and quietly dropped a blocking fact you handed it in prescan_facts — which is exactly what the reconciliation contract exists to make checkable.

The task field, before anything else

This app is three reviews behind one endpoint. task chooses which one you get, it is always present in a well-formed request, and it changes the shape of body in the reply. Everything else in the input — the PSM table, the parameters, the goal, the stage, the question, the prescan facts — is identical across all three. Send the same input three times with three different task values and you get three documents about one run.

#tasknamethe question it answerswhat body carries
1identIdentification confidence Are these peptides and proteins reportable? Recomputes the error rate from the decoys in the list, reads the digestion, the mass accuracy, the modifications, the inference and the contaminants, and says which identifications may be reported and which may not. fdr_assessment, evidence_review, drop_list, trust_scope
2quantQuantification readiness Can the intensities in this same export carry a quantitative claim? Reads the measurement columns, the missingness, the normalisation, the replication and the statistics, and names the smallest reportable result. quant_readiness, normalisation, missingness, design_review, changes, min_reportable
3methodsWrite it up and deposit it What can I actually write down, and what does a repository still want? Drafts the methods paragraph, the reporting table with a provenance on every row, the deposition checklist, the limitations and the open items. methods_paragraph, reporting_table, deposition_checklist, limitations, open_items

The three lanes are a pipeline, and they are worth running in this order, because each one can invalidate the next. An export whose decoys imply a 1.7% error rate against a claimed 1% makes every fold change in quant a fold change between two lists of unknown purity; a quant readiness call written before the identifications have been judged prescribes a normalisation for intensities attached to peptides that should have been dropped; and methods is worth writing only once ident has stopped finding things. Run ident on the first search, quant when the identifications hold, and methods when both do.

The lanes are not interchangeable and they are not additive. A reply never blends two lanes' body shapes — a merged body fails to render — and the lane's own question decides where a fact lands. One example, the one that comes up most: 1,483 of 6,214 protein groups resting on a single peptide, with the FDR controlled at the PSM level is an evidence_review row on inference plus a trust_scope.may_not_report entry in ident; it is a design_review row in quant, because a one-peptide group's intensity is one peptide's intensity and the fold change inherits its variance; and in methods it is one reporting_table row, one deposition_checklist item on the inference settings, and one limitations sentence. One fact, three places, and it must be reported at the same severity in all three. Let the lane decide where it goes; never let it decide how bad it is.

If task is absent or unrecognised the run does not fail. The model picks the lane the input best fits — a bare PSM table with decoys and q-values and no question is ident, an export whose columns are mostly intensities with a stated design is quant, a run with a deposition deadline in question is methods — sets lane to whatever it chose, and says so in the first sentence of summary. What it never does is blend two contracts to cover itself. That fallback exists so a malformed request still returns something useful, not so you can skip the field: read lane from the reply before you read body, and send the lane.

A note on what the three lanes share, since it is the reason one request shape is enough. All three read the same two artefacts: the table says what came back, row by row, and the parameters say what was asked for — the database, the enzyme, the tolerances, the FDR method and level, the quantification and the design. Almost every interesting finding in this app is a disagreement between those two. A parameter block that says "1% FDR, PSM level" next to a filtered list whose own decoys imply 1.74% is not a table problem or a prose problem; it is the finding. Send both.

The request body

Every field is top-level. task and psms are required; everything else is optional and absent means absent — nothing is defaulted on your behalf, and nothing in the reply may assume a field you did not send.

{
  "task": "ident",
  "psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,decoy\nAAGVNVEPFWPK,2,672.8531,5.8,42.11,28.4,0.0004,P60709,target\n...",
  "params": "Instrument: Orbitrap Exploris 480\nSearch engine: MSFragger 4.0\nFDR: 1%, PSM level\n...",
  "goal": "publication",
  "stage": "first_search",
  "question": "can I report the 6,214 protein groups as a 1% FDR list?",
  "prescan_facts": { }
}
fieldtypemeaning
taskstring, requiredThe lane: ident, quant or methods. See above. Absent or unrecognised does not fail — the model picks the closest lane and names it in lane — but it is not a field to leave out on purpose.
psmsstring, requiredThe PSM, peptide or protein export of one run, as text. CSV, TSV, a markdown pipe table or whitespace-aligned columns all parse; send whatever your engine already wrote rather than reformatting it. Column names are matched as whole normalised tokens against a long alias list, so Annotated Sequence, Modified sequence and peptide are one column, and q_value, PSM q-value, global q-value and FDR are another. The columns read are listed below. Large exports are sampled, not truncated: the browser sends a golden-ratio low-discrepancy draw across the whole table with the extreme row of every check forced in — the worst q-value, the largest absolute mass error, the highest missed-cleavage count, the shortest and longest peptide, at least one decoy row if any decoy exists, and one row per protein group while the budget allows — and announces the cut in-band with the real row count, so a reply can say that a statistic was computed over all rows while only a sample was shown.
paramsstring, optional — but it is what turns on most of the reviewThe search parameters and run notes: labelled lines (Enzyme: trypsin, FDR: 1% PSM level), prose, or both. Thirty reporting items are read out of it — instrument, acquisition mode, gradient, sample, search engine, database and its entry count, enzyme, missed cleavages, fixed and variable modifications, precursor and fragment tolerances, FDR method, decoy strategy, threshold and level, minimum peptides per protein, protein inference, rescoring, quantification method, normalisation, missing-value handling, replicates, statistical test, multiple-testing correction, match-between-runs, contaminant handling, deposition and software versions. Each resolves to stated, none or missing, and the middle state is a real answer rather than a gap: Imputation: none is a careful methods section, not an incomplete one.
goalstringWhat the output is for: publication, repository_deposition, internal_review, decision or self_check. It sets register and emphasis, never severity. A publication run writes text you can paste and is strict about what may be claimed from which error rate; repository_deposition leads with what PRIDE or MassIVE will ask for and what is still missing from the submission; decision leads with what the run does and does not license you to decide; internal_review is written for a colleague who knows the instrument; self_check is blunt and short.
stagestringWhere the run is in the workflow: first_search, after_rescore, final_check or reviewer_response. It sets the bar, not the findings. A 24% one-peptide protein share at first_search is expected and instructive; the same share at final_check is a blocking objection to the protein list, and at after_rescore it means the rescoring did not do what it was added for and the reply says which part of it clearly did not take.
questionstring, optionalFree text: the reviewer comment you are answering, the deadline, the claim you want to make, a constraint you cannot change (a database you are not allowed to re-search, a single replicate you cannot add). Often the most decisive field in the whole input, and it is not decorative: every claim in it comes back as one context_notes entry marked honoured, contradicted or unverifiable. A stated constraint that nothing in the review reflects is itself a finding.
prescan_factsobject, optionalWhat the browser's own free reader already computed, passed in so the lane is held to it: {coverage, table, settings, fdr, digestion, calibration, modifications, inference, contamination, quantitation, spectra, verdict, severity_counts, severity_by_area, flags, unassessable}. flags is a contract; the rest is arithmetic the reply must not contradict. See below.

The columns the table reader looks for, and what each one turns on. A column that is absent is not an error — it closes the checks that depend on it, and those land in unassessable rather than being inferred from something else:

columnread aswhat it enables
peptidesequence, modifications stripped for the arithmetic and kept for the modification checksLength distribution, missed cleavages against the stated enzyme, distinct peptide counts, the modification tokens.
chargeintegerCharge distribution; the singly-charged share, which is where a tryptic DDA run's questionable identifications concentrate; the high-charge share.
precursor_mznumberReported alongside the mass error; on its own it establishes little, which is why the ppm column matters more.
mass_error_ppmsigned numberMedian offset, MAD, 5th and 95th percentiles, and the count outside the stated precursor tolerance. A systematic offset is a calibration finding, not a per-identification one.
rtnumberRetention-time span, which is what makes a gradient claim checkable.
scorenumberOnly whether a score column exists at all and its median; an engine score is not comparable across engines and this app does not pretend otherwise.
q_valuenumber in [0, 1]Filtering at the stated threshold, the count of rows above it, and the maximum q in the list.
pepposterior error probabilityA second confidence axis. Matched on exactly pep, posterior_error_probability and their spellings — peptide is never read as pep.
proteinaccession or group, split on ;Protein group count, peptides per group, the one-peptide share, shared-accession rows, and the decoy read when there is no decoy column.
descriptionprotein name textThe contaminant check. An export whose accession column is a bare P02768 and whose description says "Serum albumin" carries the commonest contaminant in proteomics, and matching the accession alone finds nothing.
decoyboolean-ish flagThe whole target-decoy arithmetic. Absent, decoys are read from accession prefixes (DECOY_, rev_, REVERSED); absent both, no error rate can be recomputed and every FDR check is unassessable.
modsmodification listThe modification checks, together with the tokens inside the sequence.
spectrumscan or spectrum idDistinct spectra, and how many carry more than one PSM — which is how a list that looks twice as deep as it is gets caught.
any intensity columnnumber, recognised by shapeEverything the quant lane does. Recognised by stem — Intensity 3, LFQ intensity WT_2, Abundance: F1: 126, iBAQ, Reporter ion 127N, Area, Ratio — so the column count is the measurement count, not the column count of the file.

Two absences that are not the same thing, and the reply is held to the difference. No decoy information at all means the error rate was never checked — the reply says "no decoy column and no decoy-prefixed accessions, so the q-values have to be taken on trust", never "the FDR is fine". And in the parameters, a quantity the author explicitly says was not done (Normalisation: none, Imputation: none) is a stated absence, which is a stronger answer than a quantity the block never reaches: the first is a decision, the second is an omission. Never flatten those two into "missing". The same distinction is what the methods lane's source column exists to carry: computed, reported and not_established are three different provenances and only the first two can go in a paper.

prescan_facts, and the reconciliation contract

In the browser, prescan_facts comes from the free in-page read that runs before anyone signs in — the pass described under what the browser does before you pay. It resolves thirty reporting items to one of three states, recomputes the false-discovery rate from the decoys in the list under both target-decoy conventions, recomputes the missed cleavages from the sequences against the stated enzyme, summarises the mass-error distribution against the stated tolerance, counts the contaminant classes, counts the protein groups and their peptide support, and measures the missingness of every intensity column. It costs nothing and it never calls a model.

An API caller does not have to reproduce any of that. Sending no prescan_facts at all is legitimate, and the review still works — the model reads psms and params either way.

What makes it worth sending is the contract. Every uid you put in flags comes back exactly once in the response's reconciliation array — no more, no fewer, and no uid you did not send — each with a status:

statusmeans
confirmedThe reviewer agrees, at the same or a higher severity. A useful confirmed adds the consequence the flag itself does not state — not "yes, the recomputed FDR is 1.74%" but "1.74% over 40,453 filtered rows means roughly 700 of the peptides in this list are wrong, and because the FDR was controlled at the PSM level the protein-level error is higher still, so the 6,214-group count is not a 1% list in any sense".
adjustedReal, but the severity or the reading changes — and the note says what changed it. A 31% missed-cleavage share is high on a run whose parameters claim a complete overnight digest, and medium on a run whose parameters say the enzyme was semi-specific, because then a missed cleavage is expected rather than a failure.
set_asideNot a problem here, with the reason that makes it harmless. A set_aside with no reason is worse than no entry at all.
notedCarried forward without a judgement, because the lane's question does not turn on it but the reader should still see it. A contaminant count in the quant lane, where it belongs in design_review rather than in the readiness call.
not_applicableThe flag does not apply to this lane's question — a normalisation flag in an ident run that never looked at an intensity column, a deposition-coverage item in quant.

That turns a fact your own tooling established into something the reply is held to. A uid that never appears is a failed run, not a passing one. Asserting both directions in your client is three lines and it catches the one failure this app cares most about: a fluent, well-written review that quietly dropped the blocking fact you handed it. The check is written out in the verification step.

uid is the flag's stable handle within one prescan — P01, P02, … in the order the checks run, which is by area: fdr, then digestion, calibration, modification, inference, contamination, quantitation, design, reporting. Send them as you received them; they are opaque to the model, which reconciles them by identity rather than by parsing them, and the area and title beside each one carry the meaning. Each flag also carries the fields the reply is allowed to quote back:

flag fieldtypemeaning
uidstringThe handle. Reconciled by identity, exactly once.
areaenumOne of the nine areas listed with the output contract.
severityenumThe severity after mitigation, which is the one the reply is held to.
base_severityenumWhat the check would have raised with no mitigating fact. Sent so the reply can see that a step-down already happened and not step it down twice.
mitigated_bystring[]Why it stepped down, in the words of the parameter that did it — "the parameters say the search was semi-specific", "the parameters say the run was recalibrated". Empty when nothing mitigated it.
titlestringOne line, the check's own name for the problem.
detailstringThe prescan's own explanation. The reply is not required to repeat it and is required not to contradict it.
evidencestringThe arithmetic or the source text behind it, clipped to 200 characters.
rowinteger or nullA 1-based row index into the table as read, when the flag came from one row.
lineinteger or nullA 1-based line number in params, when the flag came from the parameter block.
value, thresholdnumber or nullThe measured number and the number it was compared against, when the check is a threshold check. Present so the reply can quote them without recomputing them differently.

The rest of prescan_facts is arithmetic the reply must not contradict:

keyshapewhat it is for
coverage{answered, total, stated_none[], never_mentioned[]}The reporting-checklist roll-up over the parameter block. total is the fixed number of items the free read looks for (thirty); answered is how many the parameters resolved; the two arrays are the items explicitly declared absent and the items never mentioned — and those are different findings, which is why they are different arrays rather than one "missing" list.
table{rows_total, rows_sent, sampled, columns_read[], quant_columns, distinct_peptides, distinct_modified_peptides, protein_groups, spectra_distinct}What was parsed and what was actually sent. rows_total is the whole export; rows_sent is the sample in psms. Every statistic in prescan_facts is computed over rows_total, which is why a reply may cite a count larger than the rows it can see — and why it must not cite a row index that is not in the sample.
settings{enzyme, enzyme_stated, tolerance_ppm, tolerance_unit, q_threshold, q_threshold_stated, fdr_level, decoy_search, decoy_generation, missed_allowed, min_peptides, database_entries, replicates, quant_method}The parameters, already parsed, each with a companion boolean where an assumption was needed. enzyme_stated: false with enzyme: "trypsin" means the missed-cleavage arithmetic assumed trypsin — a reply that presents that as something you said is contradicting the input.
fdr{decoy_source, strategy, claimed_threshold, claimed_level, whole_list{targets,decoys,separate,concatenated}, at_threshold{...}, rows_above_threshold, decoys_above_threshold, q_values_present, decoy_status_unresolved, max_q}The error-rate arithmetic, under both conventions, because they differ by close to a factor of two: separate target and decoy searches give D/T, one concatenated database gives 2D/(T+D). decoy_source is column, accession or none; when it is none the whole block is null and every FDR check is unassessable.
digestion{enzyme_used, enzyme_assumed, missed_known, missed_any, missed_any_share, missed_max, length_median, length_min, length_max, short_share}Recomputed from the sequences, not read from a column. short_share is the fraction under seven residues, which is the length below which a tryptic peptide is rarely proteotypic and therefore cannot carry a protein identification on its own.
calibration{ppm_known, ppm_median, ppm_mad, ppm_p05, ppm_p95, ppm_min, ppm_max, tolerance_ppm, outside_tolerance, outside_share}The precursor mass-error distribution. The median is the systematic offset and the MAD is the spread; a large offset with a small spread is a calibration problem and not an identification problem, and the reply is expected to say which of the two it is.
modifications{rows_with_any_token, tokens{}, cys_peptides, cys_with_fixed_mod, cys_check_assessable, cys_missing_fixed_mod, met_peptides, met_oxidised, ox_check_assessable, met_ox_share}The *_assessable booleans are load-bearing. Most engines apply a fixed modification silently and never write it into the reported sequence, so a correctly configured run shows zero carbamidomethyl tokens — and reporting that as "every cysteine peptide is missing the fixed modification" is an inverted finding of exactly the kind that corrupts a whole review. When the flag is false the check did not run and the reply must not run it either.
inference{assessable, protein_groups, one_peptide_groups, one_peptide_share, median_peptides_per_group, max_peptides_per_group, rows_with_shared_accessions, declared_peptide_counts}The protein-level picture. one_peptide_share against settings.min_peptides is the pair that matters: a 24% one-peptide share is a consequence of min_peptides: 1, not an accident, and the reply should say so rather than treating it as a surprise.
contamination{assessable, matched_on, rows, share, by_class[{key,label,rows}]}cRAP classes matched on accession and description together. matched_on says which, because "accession only" on an export whose accessions are bare UniProt ids finds almost nothing and the reply should not read a zero there as a clean run.
quantitation{columns[{name,present,missing,missing_share,min,max,median,log2_range}], column_count, comparable, rows_complete_across_all_columns, complete_share}Everything the quant lane needs. comparable is false below two measurement columns, and then the readiness call is a statement about what cannot be compared rather than a judgement of a comparison.
spectra{assessable, distinct, with_multiple_psms, max_psms_per_spectrum}Whether one spectrum is carrying several PSMs, which is how a list looks deeper than it is. assessable: false when no spectrum or scan column was found.
verdict"reportable" | "reportable_with_caveats" | "revise" | "not_reportable" | "unassessable"What the free read concluded from its own flags alone. The model's verdict may differ, and when it is more favourable the summary has to say what justified the move.
severity_counts{blocking, high, medium, low, info}How many flags of each severity. The cheapest cross-check there is: a reply whose verdict is reportable against a non-zero blocking count has contradicted the input, not the model.
severity_by_area{area: {blocking, high, medium, low, info}}Severity crossed with area, because the verdict floors are per-lane and per-area: a blocking fdr fact binds the ident lane's verdict, a blocking design fact binds quant, and a blocking reporting fact binds methods. Shipping the cross-tabulation rather than prose means the rule is a lookup against this object and cannot contradict it.
flags[{uid, area, severity, base_severity, mitigated_by[], title, detail, evidence, row, line, value, threshold}]The contract above.
unassessable[{item, why}]Checks the free read could not make. These are not flags and not part of the reconciliation contract; the reply is expected to carry them forward into its own unassessable rather than pretend the check was made.
"prescan_facts": {
  "coverage": { "answered": 26, "total": 30,
                "stated_none": ["normalisation", "missing_values", "multiple_testing"],
                "never_mentioned": ["rescoring", "fragment_tolerance", "gradient",
                                    "match_between_runs"] },
  "table": { "rows_total": 41207, "rows_sent": 150, "sampled": true,
             "columns_read": ["peptide", "charge", "precursor_mz", "mass_error_ppm", "rt",
                              "score", "q_value", "protein", "description", "decoy",
                              "mods", "spectrum"],
             "quant_columns": 4, "distinct_peptides": 28911,
             "distinct_modified_peptides": 31004, "protein_groups": 6214,
             "spectra_distinct": 39018 },
  "settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
                "tolerance_unit": "ppm", "q_threshold": 0.01, "q_threshold_stated": true,
                "fdr_level": "psm", "decoy_search": "concatenated",
                "decoy_generation": "reversed", "missed_allowed": 2, "min_peptides": 1,
                "database_entries": 20428, "replicates": 4,
                "quant_method": "label-free (LFQ)" },
  "fdr": { "decoy_source": "column", "strategy": "concatenated",
           "claimed_threshold": 0.01, "claimed_level": "psm",
           "whole_list": { "targets": 40251, "decoys": 956,
                           "separate": 0.023751, "concatenated": 0.046400 },
           "at_threshold": { "targets": 40101, "decoys": 352,
                             "separate": 0.008778, "concatenated": 0.017403 },
           "rows_above_threshold": 754, "decoys_above_threshold": 604,
           "q_values_present": 41207, "decoy_status_unresolved": 0, "max_q": 0.191 },
  "digestion": { "enzyme_used": "trypsin", "enzyme_assumed": false, "missed_known": 41013,
                 "missed_any": 12714, "missed_any_share": 0.31, "missed_max": 3,
                 "length_median": 13, "length_min": 6, "length_max": 41,
                 "short_share": 0.021 },
  "calibration": { "ppm_known": 41190, "ppm_median": 5.9, "ppm_mad": 1.8, "ppm_p05": 3.1,
                   "ppm_p95": 8.4, "ppm_min": -1.2, "ppm_max": 14.7,
                   "tolerance_ppm": 20, "outside_tolerance": 0, "outside_share": 0 },
  "modifications": { "rows_with_any_token": 19442,
                     "tokens": { "Carbamidomethyl (C)": 14877, "Oxidation (M)": 4104,
                                 "Acetyl (Protein N-term)": 461 },
                     "cys_peptides": 14903, "cys_with_fixed_mod": 14877,
                     "cys_check_assessable": true, "cys_missing_fixed_mod": 26,
                     "met_peptides": 9318, "met_oxidised": 1042,
                     "ox_check_assessable": true, "met_ox_share": 0.112 },
  "inference": { "assessable": true, "protein_groups": 6214, "one_peptide_groups": 1483,
                 "one_peptide_share": 0.2387, "median_peptides_per_group": 3,
                 "max_peptides_per_group": 214, "rows_with_shared_accessions": 3902,
                 "declared_peptide_counts": false },
  "contamination": { "assessable": true, "matched_on": "accession and description",
                     "rows": 1146, "share": 0.0278,
                     "by_class": [{ "key": "keratin", "label": "Keratins", "rows": 611 },
                                  { "key": "albumin", "label": "Serum albumin", "rows": 248 },
                                  { "key": "trypsin", "label": "Trypsin", "rows": 187 },
                                  { "key": "casein", "label": "Caseins", "rows": 100 }] },
  "quantitation": { "column_count": 4, "comparable": true,
                    "columns": [
                      { "name": "Intensity_DMSO_1", "present": 30104, "missing": 11103,
                        "missing_share": 0.2694, "min": 4100, "max": 1.84e9,
                        "median": 1240000, "log2_range": 18.8 },
                      { "name": "Intensity_DMSO_2", "present": 29881, "missing": 11326,
                        "missing_share": 0.2749, "min": 3900, "max": 1.71e9,
                        "median": 1190000, "log2_range": 18.7 },
                      { "name": "Intensity_STS_1", "present": 28744, "missing": 12463,
                        "missing_share": 0.3024, "min": 4400, "max": 1.66e9,
                        "median": 1010000, "log2_range": 18.5 },
                      { "name": "Intensity_STS_2", "present": 27990, "missing": 13217,
                        "missing_share": 0.3207, "min": 4200, "max": 1.59e9,
                        "median": 968000, "log2_range": 18.5 }],
                    "rows_complete_across_all_columns": 25548, "complete_share": 0.6199 },
  "spectra": { "assessable": true, "distinct": 39018, "with_multiple_psms": 1904,
               "max_psms_per_spectrum": 2 },
  "verdict": "not_reportable",
  "severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
  "severity_by_area": {
    "fdr":           { "blocking": 1, "high": 1, "medium": 1, "low": 0, "info": 0 },
    "digestion":     { "blocking": 0, "high": 0, "medium": 1, "low": 0, "info": 0 },
    "calibration":   { "blocking": 0, "high": 0, "medium": 1, "low": 0, "info": 1 },
    "modification":  { "blocking": 0, "high": 0, "medium": 0, "low": 0, "info": 1 },
    "inference":     { "blocking": 0, "high": 1, "medium": 0, "low": 0, "info": 0 },
    "contamination": { "blocking": 0, "high": 0, "medium": 1, "low": 0, "info": 0 },
    "quantitation":  { "blocking": 0, "high": 1, "medium": 0, "low": 0, "info": 0 },
    "design":        { "blocking": 1, "high": 0, "medium": 0, "low": 0, "info": 0 },
    "reporting":     { "blocking": 0, "high": 0, "medium": 0, "low": 1, "info": 0 }
  },
  "flags": [
    { "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
      "mitigated_by": [],
      "title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
      "detail": "Recomputed from the rows at or below the stated threshold, the false-discovery rate is 1.74% against a claimed 1%. Either the filter was not applied to this export, or the q-values were computed over a different set than the one pasted.",
      "evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
      "row": null, "line": 11, "value": 0.017403, "threshold": 0.01 },
    { "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
      "mitigated_by": [],
      "title": "Protein-level claims from a PSM-level error rate",
      "detail": "The FDR was controlled at the PSM level and 6214 protein groups are being reported from it. PSM-level control does not bound the protein-level error, and on a list this size the protein-level rate is typically several times higher.",
      "evidence": "6214 protein groups, FDR stated at the PSM level",
      "row": null, "line": 11, "value": 6214, "threshold": null },
    { "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
      "mitigated_by": [],
      "title": "Just under a third of peptides carry at least one missed cleavage",
      "detail": "12714 of 41013 peptides have an internal cleavage site the enzyme did not cut, computed with the trypsin rule including the proline exception. At this level the digest was incomplete, which suppresses identification rate and skews label-free quantification toward whatever digested well.",
      "evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
      "row": null, "line": 6, "value": 0.31, "threshold": null },
    { "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
      "mitigated_by": [],
      "title": "Nearly a quarter of protein groups rest on a single peptide",
      "detail": "1483 of 6214 groups are supported by one peptide, and the parameters state a minimum of one peptide per protein. That is a choice rather than an accident, and it means the protein list carries a much higher error rate than the peptide list it was built from.",
      "evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
      "row": null, "line": 13, "value": 0.2387, "threshold": null },
    { "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
      "mitigated_by": [],
      "title": "Label-free intensities with normalisation stated as none",
      "detail": "Four MS1 intensity columns with a stated normalisation of none. Column medians differ by more than 20% across the four, so any fold change taken from these columns is part loading difference.",
      "evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
      "row": null, "line": 15, "value": null, "threshold": null }
  ],
  "unassessable": [
    { "item": "the transferred-identification error rate",
      "why": "match-between-runs is not mentioned in the parameters, so whether any of these rows are transferred cannot be established" },
    { "item": "peptide-level and protein-level q-values",
      "why": "the export carries one q-value column and the parameters state the PSM level, so no peptide- or protein-level rate is present to check" }
  ]
}

One asymmetry worth knowing before you build on this. The prescan's flags are things it could prove from the two artefacts — arithmetic, a threshold, a contradiction between a stated setting and a computed count. It never flags a judgement, and it never guesses: the target-decoy arithmetic runs only when a threshold and resolvable decoys both exist, so it cannot fire on an assumption. A prescan with an empty flags array and a verdict of reportable is therefore not a clean bill of health; it means nothing checkable was wrong, which is precisely when a lane is worth paying for.

The output contract

data.output.output is a string holding one JSON object — no preamble, no code fence, no prose outside it. The web app still takes everything from the first { to the last } before parsing, and a caller should do the same: it costs one slice and it survives the small variations a model produces.

Eleven keys, identical in all three lanes except body:

{
  "lane": "ident | quant | methods",
  "title": "short name for this review, naming the run as the parameters name it",
  "verdict": "reportable | reportable_with_caveats | revise | not_reportable | unassessable",
  "headline": "one sentence naming the single fact that decides the verdict",
  "summary": "two to five sentences a proteomics analyst can act on. No restating of the JSON.",
  "findings": [
    {
      "id": "F-001",
      "severity": "blocking | high | medium | low | info",
      "area": "fdr | digestion | calibration | modification | inference | contamination | quantitation | design | reporting",
      "title": "one line",
      "detail": "what is wrong, what it does to the claim, and what makes it this severity and not another",
      "evidence": "the exact cell, count or parameter line this rests on",
      "row": 118,
      "fix": "the concrete change, with the actual number or setting named"
    }
  ],
  "reconciliation": [
    { "flag_uid": "P01",
      "status": "confirmed | adjusted | set_aside | noted | not_applicable",
      "note": "why" }
  ],
  "caveats": [
    { "area": "fdr",
      "fact": "a fact established elsewhere in this run that constrains this lane",
      "why_it_matters": "what it forbids saying, or forces hedging, here" }
  ],
  "context_notes": [
    { "claim": "what you said in question",
      "status": "honoured | contradicted | unverifiable",
      "note": "what the table and the parameters actually support" }
  ],
  "unassessable": [
    { "item": "the check that could not be made", "why": "what was missing from the input" }
  ],
  "body": { }
}
keytypemeaning
laneenumThe lane that actually ran. Normally it echoes task; when task was absent or unrecognised it is the lane that was chosen, and summary says so in its first sentence. Read this, not your own request, before you read body — a lane you did not ask for is also what a wrapped input key looks like from the outside.
titlestringShort name for the review, naming the run as the parameters name it — HeLa staurosporine DDA, first search rather than Proteomics review.
verdictenumOne of five values, below. The single field a release gate should branch on.
headlinestringOne sentence naming the single fact that decides the verdict — not a summary of the findings, the one that swung it. "The decoys at q ≤ 0.01 imply 1.74%, so this is not a 1% list" is a headline; "several confidence issues were found" is not.
summarystringTwo to five sentences, and not a restatement of the JSON. It is also where the exceptions are announced: a sampled table, an absent task, a verdict that moved away from the prescan's, an error rate that is a lower bound because the decoy status of some rows could not be read.
findingsobject[]{id, severity, area, title, detail, evidence, row, fix}. Ids are F-001, F-002, … in the order reported — note that these are the reply's ids and they are not the prescan's uids. May be empty, and an empty array is a real answer; no placeholder finding is ever emitted to fill it.
findings[].rowinteger or nullA 1-based row index into the table as read, or null. Copied from prescan_facts or taken from a row visible in psms; never estimated, and never a row that is not in the sample that was sent. null is the correct answer for a finding about the list as a whole — a false row index is worse than none, because someone will open the export and look at it.
reconciliationobject[]{flag_uid, status, note}. One entry per uid you sent in prescan_facts.flags, exactly once, no more and no fewer. Empty when you sent no flags. This is the contract worth asserting.
caveatsobject[]{area, fact, why_it_matters} — the facts established outside this lane's own question that still constrain it. This is how the pipeline stays honest across three calls: a quant run over a list whose ident pass found a 1.74% empirical FDR carries that as a caveat with area: "fdr", and the readiness call is written under it. Empty is legitimate on a clean run; empty against a not_reportable prescan is a defect.
context_notesobject[]{claim, status, note}, one entry per claim in question. Send no question and this is empty; send three claims and expect three entries. An empty context_notes against a paragraph of question text means the most decisive field in the input was not read.
unassessableobject[]{item, why} — the checks that genuinely could not be made from what was sent. An honest entry here is preferred to a confident guess, and the prescan's own unassessable entries are carried forward into it rather than dropped.
bodyobjectThe lane's own document. Three shapes, one per lane, never blended — a merged body fails to render. Documented lane by lane below.

The enums

These strings are shared verbatim with the browser's own free reader, so the two never disagree about what a clean result is called. The renderer keys on them: an unrecognised value renders as an error rather than being coerced to something plausible, so treat them as closed sets.

verdictwhen
reportableNothing above info is left. The error rate holds under the convention the parameters state, the digestion and the mass accuracy are what the settings imply, the inference is bounded, and the lane's question is answered.
reportable_with_caveatsThe worst finding is medium or low. The run is usable; read the caveats before you quote a count or a fold change.
reviseThe worst finding is high. Something needs a re-search, a re-filter or a changed pipeline step before it is reportable — not a wording change.
not_reportableAt least one finding is blocking: the export does not support the claims being made from it. A recomputed error rate well above the claimed threshold, no confidence information of any kind, a fold change from one replicate, thousands of uncorrected per-protein tests. Nothing from this run goes out until it is redone.
unassessableThe input did not carry enough to reach any of the four above — a table with no confidence columns and no decoys, or a paste that parsed as one column. This is a statement about the input, not about the run, and unassessable[] then carries the specifics. It is the one verdict that is not a judgement of the science.

The verdict follows the findings, mechanically, and never contradicts them. That makes two cheap assertions available to any client: a not_reportable verdict with no blocking finding is a broken reply, and so is a reportable verdict with a finding above info. Both are worth failing on rather than rendering.

severitymeaning
blockingA stated claim is not supported by what the export itself contains. A recomputed FDR half again above the claimed threshold, no decoy information and no q-values at all, a quantitative comparison with one measurement column, per-protein tests over thousands of proteins with no multiple-testing correction, a protein list built at a threshold the rows do not respect.
highThe result may stand and cannot currently be trusted, or a reviewer will demand a re-search: protein-level claims from a PSM-level error rate, a quarter of the protein groups on one peptide, label-free intensities with no normalisation and visibly different column medians, a digest so incomplete that the identification depth is not what the method claims.
mediumReal, bounded or mitigated — worth fixing before the next run rather than before the next paper. A systematic mass offset well inside the search window, a third of peptides with a missed cleavage, a contaminant class left in the export, rows above the stated threshold still present in the file.
lowWorth naming, not worth holding the run for. An undeposited dataset, an unstated gradient, missing software versions, a fragment tolerance the parameters never give.
infoContext the reader should have. Zero rows outside the stated precursor tolerance, an oxidation share consistent with normal handling, a decoy count that reconciles exactly with the claimed threshold. info alone still permits reportable, and these are worth saying out loud — a review that only ever reports problems cannot be checked for the things it looked at and found fine.

Severity depends on the mitigating facts, and detail names the mitigation whenever the grade moved because of it. A 31% missed-cleavage share is high against parameters claiming a complete overnight digest and medium against parameters that say the search was semi-specific. A 6 ppm median offset is medium on a run that says nothing about calibration and info on one whose parameters say a lock mass was used and the search window was widened deliberately. If you diff two reviews of the same export and a severity moved, the reason is in detail.

areacovers
fdrThe error rate and everything that establishes it: decoys and how they were generated and searched, q-values, posterior error probabilities, the threshold, the level it was controlled at, and the arithmetic that connects them.
digestionEnzyme, specificity, missed cleavages, peptide length, and what an incomplete digest does to depth and to intensity.
calibrationPrecursor and fragment mass accuracy: the systematic offset, the spread, the tolerance window, rows outside it, recalibration.
modificationFixed and variable modifications as configured against the modifications actually present in the sequences, localisation confidence, enrichment, and the site-level claims that rest on them.
inferenceProtein grouping: razor and unique peptides, shared accessions, one-peptide groups, the minimum peptides per protein, and whether a protein-level claim is bounded by anything.
contaminationcRAP and its friends — keratins, albumin, trypsin, caseins — whether they were appended, whether they were removed, and what they do to a quantitative comparison when they were not.
quantitationThe measurement columns: what was measured, normalisation, missingness and how it was handled, dynamic range, ratio compression, and whether the columns are comparable at all.
designReplication and statistics: how many replicates and of what kind, the test, multiple-testing correction, batch structure, and whether the comparison being made is the comparison the design supports.
reportingWhat was written down: the database and its version, the settings, the software versions, the contaminant policy, the deposition, and every claim in the parameters the table does not support.

Nine areas, and no synonyms: identification, statistics, qc, search and proteomics are not values. If you bucket findings for a dashboard, bucket on these nine. The pairing worth internalising is fdr/inference: they are two different questions — how many of these peptides are wrong, and what does that imply for a protein list assembled from them — and collapsing them is exactly how a 1% PSM-level list gets released as a 1% protein list.

Two more closed sets, both small and both easy to get wrong. reconciliation.status is confirmed, adjusted, set_aside, noted or not_applicable — described with the reconciliation contract above. context_notes.status is honoured, contradicted or unverifiable: the first means the claim in question was addressed on its own terms, the second means the input contradicts it and the note says with which cell or which parameter line, and the third means nothing in the input speaks to it either way. unverifiable is the honest answer to "we always run a two-hour gradient" when the parameters do not say so, and it is not a criticism.

body for task: "ident"

Whether the identifications are reportable. Four blocks, ordered the way a reviewer reads an export: what the error rate actually is, what each line of evidence says, what should come out of the list, and — the block that matters most to a caller — what may and may not be reported.

fdr_assessment is a paragraph and it is the one place the arithmetic is allowed to be spelled out: which convention was used and why, what the recomputed rate is, how it compares to the claimed threshold, and what the level it was controlled at does and does not bound. When the decoy source was none it says so and says what that costs, rather than repeating the q-values as though they had been checked.

evidence_review is one row per area the lane looked at, with a one-clause judgement and a note that names the number behind it. The area is from the closed set; judgement is deliberately free text of a clause or two, because "adequate for a discovery list, not for a targeted follow-up" is the honest reading and no enum carries it — do not build a renderer that keys on it. An area the input could not speak to does not get a row here; it gets an unassessable entry.

drop_list is the actionable block: what to remove, why, and how many rows it is. It is written so it can be executed — "rows with q above the stated threshold", "peptides shorter than seven residues", "the 1,146 rows matching a cRAP class" — and count is the number of rows, taken from the prescan or from the visible table, never estimated. An empty drop_list on a clean run is a real answer.

trust_scope is the block to build tooling on. may_report and may_not_report are arrays of claims, phrased as a person would make them, and they partition what this run produced — a claim in neither is a gap, and a claim in both is a broken reply.

"body": {
  "fdr_assessment": "One paragraph: the convention used, the recomputed rate, the claimed threshold, and what the level it was controlled at bounds and does not bound.",
  "evidence_review": [
    { "area": "fdr",
      "judgement": "fail",
      "note": "The claimed 1% does not hold for this list — 2D/(T+D) over the 40,453 rows at q <= 0.01 gives 1.74%; 352 decoys survived the filter" },
    { "area": "digestion",
      "judgement": "concern",
      "note": "Incomplete, and it shows in the depth — 31% of peptides carry at least one missed cleavage against 2 allowed; median length 13" },
    { "area": "calibration",
      "judgement": "concern",
      "note": "Offset but well inside the window — median +5.9 ppm, MAD 1.8, no rows outside the stated 20 ppm" },
    { "area": "inference",
      "judgement": "fail",
      "note": "Not bounded by anything the parameters state — 1,483 of 6,214 groups on one peptide, with min_peptides = 1 and PSM-level FDR" }
  ],
  "drop_list": [
    { "what": "rows above the stated q-value threshold",
      "why": "the file was exported unfiltered, so any count taken from the row total is not the count at 1%",
      "count": 754 },
    { "what": "rows matching a cRAP contaminant class",
      "why": "the parameters say contaminants were appended and not removed; keratin and trypsin peptides are not part of the biology being compared",
      "count": 1146 },
    { "what": "protein groups supported by one peptide",
      "why": "PSM-level FDR does not bound the protein-level error, and a one-peptide group is where that gap is widest",
      "count": 1483 }
  ],
  "trust_scope": {
    "may_report": [
      "the peptide-level identifications for groups with three or more peptides, described as a PSM-level 1.7% list rather than a 1% list",
      "the presence or absence of a named protein with several peptides across replicates"
    ],
    "may_not_report": [
      "the 6,214 protein groups as a 1% FDR protein list",
      "any single-peptide identification as a detection",
      "peptide or protein counts taken from the unfiltered row total"
    ]
  }
}

Two things the ident lane will not do. It will not tell you the biology is wrong — a technically impeccable run of the wrong experiment is reportable here, and that is the honest answer to the question this lane asks. And it will not compute a rate the export does not support: no decoy column and no decoy-prefixed accessions means fdr_assessment says so and an unassessable entry appears, rather than an inference from the q-values that were never checked.

body for task: "quant"

Whether the intensities in the same export can carry a quantitative claim. Six blocks, and they are ordered as the decision is actually made: is this comparable at all, was it normalised, what is missing and how was that handled, does the design support the comparison, what would you change, and — the block a caller wants — what is the smallest thing that can be reported as it stands.

quant_readiness is the paragraph. normalisation.method_as_stated is quoted from the parameters verbatim, including "none" and including "not stated", because the difference matters: a stated none on TMT reporter ions is a defensible choice and an unstated one on label-free MS1 areas is a gap. judgement and note are a clause and a sentence, and the note is where the column medians go.

missingness is one judgement and one note over the whole matrix, and the note is expected to distinguish the two kinds: values missing because the peptide was below the limit of detection in that sample, and values missing because the identification was not transferred. They have opposite implications and only the parameters can tell them apart, which is why match-between-runs is one of the thirty items.

design_review is one row per design question — replication, the test, the correction, the batch structure, the contaminant policy where it touches quantification — each with a judgement clause and a note naming the number. changes is the ordered list of what to do, each with an effort: cheap is a re-analysis of the same export, moderate is a re-run of the pipeline, expensive is new mass-spectrometer time. That field exists so a caller can sort by what is actually available to them this week.

min_reportable is the sentence a person will paste. It is the strongest claim the current data supports without any of the changes above — often much weaker than the claim that was wanted, and occasionally "nothing quantitative", which is a legitimate value and is written as a sentence rather than as an empty string.

"body": {
  "quant_readiness": "One paragraph: what was measured, in how many columns, over what design, and whether a fold change from it means anything yet.",
  "normalisation": {
    "method_as_stated": "none",
    "judgement": "fail",
    "note": "Required here and absent — Four label-free MS1 intensity columns whose medians run 1.24e6, 1.19e6, 1.01e6 and 9.68e5 - a 28% spread between the extremes. Without a median or quantile normalisation, part of every fold change is the loading difference between injections."
  },
  "missingness": {
    "judgement": "fail",
    "note": "High and not at random — 38% of rows are incomplete across the four columns and the missing share rises monotonically from 26.9% in DMSO_1 to 32.1% in STS_2, which is the pattern of a treatment that lowered overall intensity rather than of random dropout. The parameters say values were left as-is, so every t-test silently ran on a different subset of replicates."
  },
  "design_review": [
    { "item": "replication",
      "judgement": "concern",
      "note": "Adequate in number, unverified in kind — 4 replicates per condition are stated; the export carries 4 columns, so these are one measurement per replicate. Whether they are biological or technical is not stated, and the claim you can make differs." },
    { "item": "statistical test",
      "judgement": "fail",
      "note": "Stated but unusable as configured — A two-sample t-test per protein across 6,214 proteins with the correction stated as none. At an uncorrected 0.05, roughly 310 proteins are expected to reach significance by chance alone." },
    { "item": "contaminants",
      "judgement": "concern",
      "note": "Left in, and they carry intensity — 1,146 rows match a cRAP class, keratin being 611 of them. Keratin intensity varies with handling rather than with treatment and it is part of the total each column would be normalised against." }
  ],
  "changes": [
    { "change": "median-normalise the four intensity columns, then recompute the fold changes",
      "why": "removes the loading difference that is currently inside every ratio",
      "effort": "cheap" },
    { "change": "apply Benjamini-Hochberg across the per-protein tests and report q-values, not p-values",
      "why": "6,214 uncorrected tests produce hundreds of false positives at any threshold worth quoting",
      "effort": "cheap" },
    { "change": "restrict the quantitative comparison to proteins with a value in at least three of four replicates per condition",
      "why": "a fold change computed from one replicate against four is not a fold change",
      "effort": "cheap" },
    { "change": "re-run the pipeline with contaminants removed after identification rather than kept in the export",
      "why": "keratin and trypsin intensity should not be inside a normalisation total",
      "effort": "moderate" },
        { "change": "acquire a second biological set with the replicate kind recorded, and randomise the acquisition order",
          "why": "one replicate per condition cannot support a tested fold change at any correction, so no reanalysis of this export fixes it",
          "effort": "expensive" }
  ],
  "min_reportable": "With no changes at all: nothing quantitative at the protein level. The export supports a qualitative statement that 6,214 groups were identified across the eight injections and that intensities were recorded for most of them, with the caveats above. After median normalisation, a completeness filter and Benjamini-Hochberg - all three of which run on this same file - a fold-change table with q-values would be reportable for the subset of proteins measured in at least three replicates per condition."
}

The quant lane reads the identifications it was handed and does not re-judge them; it carries the ident facts in caveats instead. That is deliberate: the two questions have different answers and a run can be perfectly quantifiable and not identifiable, or the reverse. What it will not do is give you a fold change. It has the columns, and computing statistics the caller has not asked for from a sampled table would be the least defensible thing on this page.

body for task: "methods"

What can actually be written down, and what a repository still wants. This is the lane with the strongest provenance discipline, and reporting_table[].source is where it lives: computed means the number was derived here from what you sent (an empirical FDR from a decoy count, a missed-cleavage share from the sequences), reported means the parameters stated it and the value is quoted as given, and not_established means the item belongs in the table and nothing in the input establishes it. That third value is not a gap to be filled in later — it is the row, and it prints as "not reported" in the drafted table so the omission is visible rather than invisible.

methods_paragraph is written to be pasted, in the register goal asked for, and every number in it came from the input. It names the instrument, the acquisition, the software and versions, the database and its entry count, the enzyme and missed cleavages, the modifications, the tolerances, the FDR method and level and threshold, the inference rule, and the quantification — and where one of those is not_established it writes the sentence with the gap visible rather than filling it in from convention. A methods paragraph that invents "Carbamidomethylation of cysteine was set as a fixed modification" because that is what everybody does is the single most damaging thing this lane could produce.

deposition_checklist is what PRIDE, MassIVE or jPOST will ask for, each item with a status of present, missing or needs_a_number. The third one is the useful state and it is not a synonym for the second: the raw files exist and the accession does not, the search parameters exist and the exact database version string does not, the FDR is stated as 1% and the level it applies to is not. needs_a_number means you have the thing and it is not quotable yet.

limitations is an array of sentences, each one a limitation a reviewer will raise, in the order they will raise it — not a paragraph, because a client usually wants to render them as bullets under a methods draft. open_items is what has to happen before submission, also as sentences, and the two are kept apart because one is text for the paper and the other is work for a person.

"body": {
  "methods_paragraph": "Peptides were identified with MSFragger 4.0 (FragPipe 21.1) against the UniProt human reference proteome (UP000005640, 20,428 entries) with reversed decoys appended to a single concatenated database. Trypsin was specified with up to two missed cleavages, carbamidomethylation of cysteine as a fixed modification and oxidation of methionine and protein N-terminal acetylation as variable modifications, with a 20 ppm precursor and 0.02 Da fragment tolerance. Peptide-spectrum matches were filtered to 1% false discovery rate at the PSM level using Percolator q-values; the decoys surviving that filter in the exported list correspond to 1.74% by the concatenated-database estimator, which is reported here rather than the nominal threshold. Proteins were assembled with razor peptides and single-peptide identifications were retained, so 1,483 of 6,214 groups rest on one peptide. Label-free quantification used MS1 areas from IonQuant; no normalisation, imputation or multiple-testing correction was applied. The LC gradient, the fragment-tolerance rationale and the rescoring configuration are not established by the available parameters and are omitted rather than assumed.",
  "reporting_table": [
    { "item": "Instrument", "value": "Orbitrap Exploris 480", "source": "reported" },
    { "item": "Acquisition", "value": "DDA, top-20", "source": "reported" },
    { "item": "LC gradient", "value": "not reported", "source": "not_established" },
    { "item": "Search engine", "value": "MSFragger 4.0 (FragPipe 21.1)", "source": "reported" },
    { "item": "Database", "value": "UniProt UP000005640, 20,428 entries", "source": "reported" },
    { "item": "Enzyme and missed cleavages", "value": "trypsin, 2", "source": "reported" },
    { "item": "Fixed modifications", "value": "carbamidomethyl (C)", "source": "reported" },
    { "item": "Variable modifications", "value": "oxidation (M), acetyl (protein N-term)", "source": "reported" },
    { "item": "Precursor tolerance", "value": "20 ppm", "source": "reported" },
    { "item": "Observed precursor mass error", "value": "median +5.9 ppm, MAD 1.8 ppm", "source": "computed" },
    { "item": "FDR method, level and threshold", "value": "Percolator q-values, PSM level, 1%", "source": "reported" },
    { "item": "Empirical FDR in the exported list", "value": "1.74% at q <= 0.01 by 2D/(T+D)", "source": "computed" },
    { "item": "PSMs", "value": "41,207 rows exported, 40,453 at q <= 0.01", "source": "computed" },
    { "item": "Distinct peptides", "value": "28,911 stripped, 31,004 modified", "source": "computed" },
    { "item": "Protein groups", "value": "6,214, of which 1,483 single-peptide", "source": "computed" },
    { "item": "Missed cleavages", "value": "31% of peptides carry at least one", "source": "computed" },
    { "item": "Contaminant rows", "value": "1,146 (2.8%), cRAP appended and retained", "source": "computed" },
    { "item": "Quantification", "value": "label-free MS1 area (IonQuant), match-between-runs not stated", "source": "reported" },
    { "item": "Normalisation", "value": "none", "source": "reported" },
    { "item": "Multiple-testing correction", "value": "none", "source": "reported" },
    { "item": "Repository accession", "value": "not reported", "source": "not_established" }
  ],
  "deposition_checklist": [
    { "item": "Raw files for all eight injections", "status": "needs_a_number",
      "note": "The parameters describe 4 replicates in 2 conditions but do not say how many raw files that is or name them; a submission needs the file list." },
    { "item": "Repository accession", "status": "missing",
      "note": "Stated as not yet deposited. PRIDE issues the accession before the manuscript needs it, so this is the first thing to start." },
    { "item": "Search parameter file", "status": "present",
      "note": "The FragPipe workflow file covers engine, database, enzyme, modifications and tolerances." },
    { "item": "Exact database version string", "status": "needs_a_number",
      "note": "UP000005640 with 20,428 entries is stated; the release date or the FASTA checksum is what makes it reproducible." },
    { "item": "Decoy strategy statement", "status": "present",
      "note": "Reversed decoys, concatenated database - stated, and it is what the empirical rate above was computed under." },
    { "item": "Contaminant FASTA", "status": "present", "note": "cRAP appended, and stated as retained in the export." },
    { "item": "Quantification and normalisation settings", "status": "present",
      "note": "Stated, including the normalisation as none, which is the answer a repository wants recorded even though it is the finding above." },
    { "item": "Statistical analysis script or parameters", "status": "missing",
      "note": "A per-protein t-test is named with no script, no correction and no software version." }
  ],
  "limitations": [
    "The exported list is filtered at a nominal 1% PSM-level FDR, and its own surviving decoys correspond to 1.74% by the concatenated-database estimator; peptide counts should be read against that figure.",
    "The false discovery rate was controlled at the PSM level, so the protein-level error rate for the 6,214 groups is not bounded by the stated threshold and is expected to be several times higher.",
    "1,483 protein groups rest on a single peptide and are reported as detections without independent support.",
    "31% of peptides carry at least one missed cleavage, so identification depth and label-free intensity both reflect an incomplete digest.",
    "Intensities were not normalised and missing values were not addressed, so no fold change between the two conditions is reported here.",
    "Contaminant proteins were appended and retained in the export, and were not excluded before any quantitative summary."
  ],
  "open_items": [
    "Open a PRIDE submission and obtain the accession before the methods paragraph is finalised.",
    "Re-filter the export at the stated threshold and re-count, or state the empirical 1.74% in the text.",
    "Decide and record whether protein-level FDR will be controlled, or scope every protein claim to the peptide evidence.",
    "Record the LC gradient, the fragment-tolerance rationale, the rescoring configuration and the statistical software versions.",
    "State whether the four replicates per condition are biological or technical."
  ]
}

The methods lane is the one people are most tempted to run first, and the one that punishes it. It will not launder a run: a not_reportable ident pass turns the nominal threshold in the paragraph into the empirical one, fills limitations with the reasons, and leaves reporting_table rows at not_established where the input established nothing — which is the correct output and not a comfortable manuscript. Run it when ident has stopped objecting.

A worked ident reply, in full

This is the reply to the ident request in step 6 — a HeLa staurosporine DDA run, 41,207 exported PSMs, a nominal 1% PSM-level FDR whose surviving decoys imply 1.74%, and 6,214 protein groups of which 1,483 rest on one peptide. It is shown whole, including the parts a renderer usually hides, because the relationships between the keys are the contract: five reconciliation entries for five prescan uids, a verdict of not_reportable justified by exactly one blocking finding, a row on every finding that came from a row and null on every finding that did not, and a trust_scope that partitions the claims rather than summarising them.

{
  "lane": "ident",
  "title": "HeLa staurosporine DDA - first search, 41,207 PSMs, 6,214 groups",
  "verdict": "not_reportable",
  "headline": "The decoys surviving the stated 1% filter correspond to 1.74% by the concatenated-database estimator, so this is not a 1% list and the 6,214 protein groups are not a 1% protein list by any route.",
  "summary": "Recomputed from the rows at or below the stated threshold, the false-discovery rate is 1.74% - 352 decoys among 40,453 filtered rows under the 2D/(T+D) convention the parameters imply. That alone means the headline counts cannot be reported as stated. Two further facts compound it: the FDR was controlled at the PSM level, which does not bound the protein-level error at all, and single-peptide identifications were retained, so 1,483 of the 6,214 groups have no independent support. The digestion and the mass accuracy are workable - a 31% missed-cleavage share and a +5.9 ppm median offset well inside the 20 ppm window - and neither is why this run is blocked. The next step is to re-filter and re-count, or to state the empirical rate, and then to decide whether protein-level FDR is being controlled at all.",
  "findings": [
    {
      "id": "F-001",
      "severity": "blocking",
      "area": "fdr",
      "title": "The decoys in the filtered list imply 1.74% against a claimed 1%",
      "detail": "At q <= 0.01 the export carries 40,101 target rows and 352 decoy rows. The parameters state reversed decoys in one concatenated database, so the estimator is 2D/(T+D) = 704/40,453 = 1.74%. That is three quarters again above the nominal threshold, which is too large to be decoy-counting noise at this depth: 352 decoys is a well-determined count. Either the filter was not applied to this exported file, or the q-values were computed over a different set of spectra than the one exported. This is blocking rather than high because every count in the run - PSMs, peptides, protein groups - is quoted against the 1% figure, and each of those numbers is a claim the export itself refutes.",
      "evidence": "2D/(T+D) over 40,101 targets and 352 decoys at q <= 0.01; parameters line 11: \"FDR: 1%, controlled at the PSM level with Percolator q-values\"",
      "row": null,
      "fix": "Re-filter this export at q <= 0.01 from the q-values it carries, recount, and check the decoys again; if the counts do not change, the q-values came from a different set and the search needs re-exporting. Until then, quote 1.74% rather than 1% wherever a rate is stated."
    },
    {
      "id": "F-002",
      "severity": "high",
      "area": "fdr",
      "title": "Protein-level claims from a PSM-level error rate",
      "detail": "The threshold was applied at the PSM level and 6,214 protein groups are being reported from it. PSM-level control says nothing about how many protein groups are wrong: a single false PSM can create a protein group, and on a list of this size the protein-level rate is routinely several times the PSM-level one. This is high rather than blocking on its own terms because the peptide-level list remains meaningful; it is the protein-level claim that is unbounded.",
      "evidence": "6,214 protein groups; parameters line 11 states the PSM level and no peptide- or protein-level rate is present in the export",
      "row": null,
      "fix": "Control the FDR at the protein level as well - Percolator or Philosopher will do it on this same search - and report both rates. If that is not going to happen, scope every protein statement to its peptide evidence and do not present the group count as an FDR-controlled number."
    },
    {
      "id": "F-003",
      "severity": "high",
      "area": "inference",
      "title": "1,483 of 6,214 protein groups rest on a single peptide",
      "detail": "23.9% of the groups are one-peptide identifications, and the parameters state a minimum of one peptide per protein, so this is a configured outcome rather than an accident. Combined with F-002 it is the widest part of the gap between the peptide list and the protein list: a one-peptide group at a PSM-level threshold has exactly one piece of evidence behind it, and if that PSM is one of the wrong ones the whole protein is wrong. Median support across groups is 3 peptides, so the bulk of the list is better than this - which is the argument for scoping rather than for discarding.",
      "evidence": "1,483 of 6,214 groups have one peptide; parameters line 13: \"Protein inference: razor peptides, single-peptide identifications retained\"",
      "row": null,
      "fix": "Report the two-or-more-peptide list as the protein list and the one-peptide groups as a separate, explicitly weaker table, or raise the minimum to two peptides and re-count. Either is defensible; presenting 6,214 as one homogeneous list is not."
    },
    {
      "id": "F-004",
      "severity": "medium",
      "area": "fdr",
      "title": "The export still contains rows above the stated threshold",
      "detail": "754 of 41,207 rows sit above q = 0.01, 604 of them decoys. That is entirely normal for an unfiltered engine export and it is not itself a problem - but it means any count taken from the row total is not the count at the stated threshold, and it is the most likely explanation for how a 1.74% figure and a 1% claim came to be written in the same place.",
      "evidence": "row 9: q_value 0.0402, target; row 7: q_value 0.0193, decoy; 754 of 41,207 rows above q = 0.01",
      "row": 9,
      "fix": "Filter on the q-value column before counting anything, and state the row count before and after so the two numbers are never confused."
    },
    {
      "id": "F-005",
      "severity": "medium",
      "area": "digestion",
      "title": "Just under a third of peptides carry a missed cleavage",
      "detail": "12,714 of 41,013 peptides have an internal K or R the enzyme did not cut, computed with the trypsin rule including the proline exception, against two missed cleavages allowed. A 31% share is an incomplete digest rather than a search-space artefact - two allowances would produce a share in the low tens of per cent on a complete digest. It costs identification depth, and because label-free intensity is spread across the missed-cleavage forms of the same peptide it also biases the quantification the quant lane will look at.",
      "evidence": "row 4: VLDELTLARK carries one internal cleavage site; 12,714 of 41,013 peptides, up to 3 missed cleavages observed against 2 allowed",
      "row": 4,
      "fix": "Longer or warmer digestion, or more trypsin, on the next preparation. For this run, nothing to fix retrospectively - state the share, since it bounds how much of the proteome the depth represents."
    },
    {
      "id": "F-006",
      "severity": "medium",
      "area": "calibration",
      "title": "A systematic +5.9 ppm precursor offset inside a 20 ppm window",
      "detail": "The median mass error is +5.9 ppm with a MAD of 1.8 - a tight distribution sitting well off zero, which is a calibration offset and not a scattering of bad matches. No row is outside the stated 20 ppm tolerance, so nothing was lost to it, but a 20 ppm window that is really +2 to +10 ppm is three times wider than the data needs, and that width is search space that decoys also occupy. This is medium because it costs sensitivity and specificity rather than invalidating anything.",
      "evidence": "median +5.9 ppm, MAD 1.8, p05 3.1, p95 8.4, min -1.2, max 14.7 over 41,190 rows with a ppm value; tolerance 20 ppm",
      "row": null,
      "fix": "Recalibrate and re-search with a 10 ppm window, or apply the engine's own mass-error correction. The same spectra will yield a few per cent more identifications at the same FDR, and the decoy competition improves."
    },
    {
      "id": "F-007",
      "severity": "medium",
      "area": "contamination",
      "title": "1,146 contaminant rows, appended and retained",
      "detail": "cRAP was appended to the database and the parameters state it was not removed from the export: 611 keratin rows, 248 serum albumin, 187 trypsin, 100 casein. Keeping contaminants in the search is correct - they compete for spectra that would otherwise be mismatched to real proteins - and keeping them in the reported list is not. For identification they are 2.8% of rows that are not part of the biology; for the quant lane they carry intensity that varies with handling.",
      "evidence": "row 3: P04264 keratin type II cytoskeletal 1; row 2: P02768 serum albumin; row 10: P00761 trypsin (porcine); 1,146 of 41,207 rows across 4 cRAP classes",
      "row": 3,
      "fix": "Keep them in the database, drop them from the reported list after identification, and say in the methods that you did. Do not remove them from the FASTA."
    },
    {
      "id": "F-008",
      "severity": "info",
      "area": "calibration",
      "title": "No row falls outside the stated precursor tolerance",
      "detail": "Every one of the 41,190 rows carrying a ppm value is inside the 20 ppm window, maximum 14.7 ppm. Worth stating explicitly: the tolerance was honoured, so nothing here suggests the window itself was mis-set or that the search accepted matches it should not have. This is the check F-006 is a finding against - the offset is real and the window was still respected.",
      "evidence": "0 of 41,190 rows outside +/-20 ppm; max observed 14.7 ppm",
      "row": null,
      "fix": "Nothing. Tighten the window only in the context of F-006."
    }
  ],
  "reconciliation": [
    { "flag_uid": "P01", "status": "confirmed",
      "note": "Confirmed at the same severity, with the consequence the flag does not state: because every headline count in this run is quoted against the nominal 1%, the discrepancy is not one number being wrong but every count being labelled with a rate the list does not meet." },
    { "flag_uid": "P02", "status": "confirmed",
      "note": "Confirmed. It compounds P01 rather than duplicating it - P01 says the peptide-level rate is higher than claimed, P02 says the protein-level rate was never bounded at all, and the protein list inherits both." },
    { "flag_uid": "P04", "status": "adjusted",
      "note": "Adjusted downward in emphasis, not in severity: 31% is a real incomplete digest and it stays medium, but it is not why this run is blocked and the fix is a wet-lab one that cannot be applied to this export. Reported here so it is not lost, and flagged for the quant lane where it does bias a comparison." },
    { "flag_uid": "P06", "status": "confirmed",
      "note": "Confirmed at high. The note the flag leaves implicit: median support is 3 peptides, so the remedy is to split the list rather than to discard it, and that is what trust_scope does below." },
    { "flag_uid": "P08", "status": "not_applicable",
      "note": "Not applicable to this lane. Normalisation is a quantitation question and this lane makes no statement about the intensity columns; it is carried into caveats so the quant run inherits it rather than rediscovering it." }
  ],
  "caveats": [
    { "area": "design",
      "fact": "The parameters state 4 replicates per condition and the export carries exactly 4 intensity columns, so there is one measurement per replicate and no technical repeat behind any of them.",
      "why_it_matters": "A peptide identified in one injection is one observation, so it cannot be called a detection for the condition. This bounds what may_report below can say about presence and absence." },
    { "area": "quantitation",
      "fact": "Normalisation is stated as none across four label-free MS1 intensity columns whose medians span 28%.",
      "why_it_matters": "Nothing in this lane rests on it, but no count of identifications in this reply should be read as a quantitative statement, and the quant lane inherits it as its first finding rather than as new information." }
  ],
  "context_notes": [
    { "claim": "can I report the 6,214 protein groups as a 1% FDR protein list?",
      "status": "contradicted",
      "note": "No, on two independent grounds. The list is not at 1% - its own surviving decoys give 1.74% - and it was never protein-level controlled, so even a correctly filtered PSM-level list would not make the group count a 1% protein list. What you can report is a peptide-level list at the empirical rate, and a protein list scoped to groups with two or more peptides and described as PSM-level controlled." },
    { "claim": "we need to submit this week",
      "status": "unverifiable",
      "note": "Nothing in the table or the parameters speaks to a deadline, so this is taken as context rather than as a fact. It does change the advice: re-filtering and re-counting from this same export is an afternoon, whereas protein-level FDR control and a re-search with a tighter window are not, so the fix in F-001 is the one that fits the constraint." }
  ],
  "unassessable": [
    { "item": "the error rate of transferred identifications",
      "why": "Match-between-runs is never mentioned in the parameters and the export carries no transfer flag, so whether any of these rows are transferred rather than searched cannot be established - and a transferred identification is not covered by a search-level FDR." },
    { "item": "peptide-level and protein-level q-values",
      "why": "The export carries one q-value column and the parameters state the PSM level, so no peptide- or protein-level rate exists in the input to check F-002 against directly." },
    { "item": "site localisation for the oxidised methionine peptides",
      "why": "Modification tokens are present but no localisation probability or site-score column is, so nothing here supports or refutes a site-level claim." },
    { "item": "fragment mass accuracy",
      "why": "The parameters state a 0.02 Da fragment tolerance and the export carries no fragment-level columns, so only the precursor side could be checked." }
  ],
  "body": {
    "fdr_assessment": "The parameters state reversed decoys appended to a single concatenated database, so the estimator is 2D/(T+D) rather than D/T - the two differ by close to a factor of two here and the choice is not free. Over the whole 41,207-row export: 956 decoys against 40,251 targets, 4.64%. Over the 40,453 rows at or below the stated q <= 0.01: 352 decoys against 40,101 targets, 1.74%. The claimed threshold is 1%, and 352 is a large enough decoy count that this is not sampling noise. The decoy status of every row resolved from the decoy column, so no row was excluded from the arithmetic and 1.74% is not a lower bound. Separately, the level matters as much as the number: control at the PSM level bounds the PSM error rate and nothing else, so the 28,911 distinct peptides and the 6,214 protein groups are both being reported at unstated rates. Nothing in the input establishes a peptide-level or protein-level q-value to put in their place.",
    "evidence_review": [
      { "area": "fdr",
        "judgement": "fail",
        "note": "The claimed threshold does not hold for this list — 1.74% recomputed at q <= 0.01 under 2D/(T+D), against a claimed 1%; 754 rows including 604 decoys sit above the threshold in the file." },
      { "area": "digestion",
        "judgement": "concern",
        "note": "Incomplete, and it costs depth — 12,714 of 41,013 peptides carry a missed cleavage against 2 allowed; median length 13, 2.1% under 7 residues, longest 41." },
      { "area": "calibration",
        "judgement": "concern",
        "note": "Offset but never outside the window — median +5.9 ppm, MAD 1.8, no row beyond 14.7 ppm against a 20 ppm tolerance - a recalibration and a 10 ppm window would buy identifications." },
      { "area": "modification",
        "judgement": "pass",
        "note": "Configured as stated and written into the export — 14,877 rows carry Carbamidomethyl (C) against 14,903 cysteine-containing target peptides, so the fixed modification is present and the 26 exceptions are worth a look rather than a finding; oxidation on 11.2% of methionine peptides is unremarkable." },
      { "area": "inference",
        "judgement": "fail",
        "note": "Not bounded by anything the parameters state — 6,214 groups, 1,483 on one peptide, median 3, maximum 214; min_peptides = 1 and the FDR is PSM-level." },
      { "area": "contamination",
        "judgement": "concern",
        "note": "Present, retained, and quantitatively relevant — 1,146 rows over keratin, albumin, trypsin and casein, matched on accession and description together." }
    ],
    "drop_list": [
      { "what": "the rows above the stated q-value threshold",
        "why": "The file is an unfiltered export, so every count taken from the row total is a count at max_q 0.191 rather than at 0.01.",
        "count": 754 },
      { "what": "the rows matching a cRAP contaminant class",
        "why": "Correct to search, wrong to report: keratin, albumin, trypsin and casein are not part of the comparison and they carry intensity.",
        "count": 1146 },
      { "what": "the protein groups supported by a single peptide, as a separate table",
        "why": "Not to be deleted - to be reported separately, because a PSM-level threshold gives them no independent support and 23.9% of the list is too much to leave unqualified.",
        "count": 1483 }
    ],
    "trust_scope": {
      "may_report": [
        "the peptide-level identifications, described as a PSM-level list at an empirical 1.74% rather than at 1%",
        "the protein groups with two or more peptides - 4,731 of them - as identifications, with the error rate described as PSM-level and unbounded at the protein level",
        "the presence of a named protein supported by several peptides across both conditions",
        "the search settings and the observed mass accuracy, which are what the methods lane will need"
      ],
      "may_not_report": [
        "the 6,214 protein groups as a 1% FDR protein list",
        "any count taken from the 41,207-row total as a count at 1%",
        "a single-peptide group as a detection without saying it is one peptide",
        "the identification depth as a property of the sample rather than of an incomplete digest",
        "contaminant proteins as findings"
      ]
    }
  }
}

Read three things off that reply before anything else. lane is ident, so the body is the one documented above and the request arrived flat. reconciliation has five entries for the five uids that went in — including the not_applicable one, which is a real answer and not an omission — and that is the whole reason to send prescan_facts. And verdict is not_reportable with exactly one blocking finding behind it, so the verdict and the findings agree. Everything else on the page is easier to trust once those three hold.

One detail worth copying into your own tooling: 4,731 in trust_scope.may_report is 6,214 - 1,483, and it is there because a caller asked what may be reported and the answer is a number they can use. Every number in a reply is either quoted from the input, quoted from prescan_facts, or arithmetic over those two that the reply shows. If you find one that is none of the three, that is the bug this contract exists to make visible.

Check the reply before you trust it

The browser does not render a reply verbatim and neither should a caller. Seven assertions cover everything this app can get wrong in a way that still looks plausible, and all seven are cheap:

One more, and it is the cheapest of all: context_notes is non-empty exactly when you sent question. The assertion code for all of these is in step 4, in all eight languages.

0. A tiny client

One helper that adds the two headers, unwraps data and raises on ok: false. Two headers is the whole story: Content-Type and Authorization. If you find yourself reaching for X-App-Slug, the token already carries the app. Every later step on this page uses this helper, and if you would rather not deal with tokens in code at all, the token page prints the one this browser already holds, with a copy button and a ready-made shell export — no DevTools, no console.

# Every call is the same three things: the base URL, your bearer token, and a
# JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"   # from https://psm-desk.skillsafe.ai/tokens.html

call() {                   # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

# Unwrap the envelope: print data, or exit non-zero with the API error code.
data() {
  python3 -c '
import sys, json
env = json.load(sys.stdin)
if not env.get("ok"):
    err = env.get("error") or {}
    raise SystemExit("%s: %s" % (err.get("code"), err.get("message")))
json.dump(env["data"], sys.stdout)
'
}

call me | data
# {"subject_type": "user", "subject_id": "usr_...", "credits": 51234}
#
# No X-App-Slug header. The slug was only ever needed by POST /guest.
# The three lanes, for reference:  ident  quant  methods

1. Get a token

For a human, the shortest path is the token page — that is the link to follow if you would rather not touch DevTools. It shows the token this browser already holds, with a copy button and a ready-made shell export, and a sign-in button for a personal token. Nothing on it needs a developer tool: it reads the same storage the app itself uses and prints the token for you.

For a program, POST /guest mints one. The body is {"slug": "psm-desk"} — this is the one and only place the slug appears in this API — and the call answers 201 Created:

HTTP/1.1 201 Created

{ "ok": true, "data": {
    "token": "sk_guest_...",
    "guest_id": "gst_...",
    "expires_at": "2026-08-27T09:14:02Z"
} }

Three things follow from that shape. expires_at is real, so a long-lived worker re-mints rather than caching forever; a 401 on a previously good token usually means it lapsed. guest_id is worth keeping — it is what lets a later sign-in migrate the guest wallet, and it is the only handle you have on an anonymous session. And a guest token is enough for /me and /estimate but not for a metered run: a review is metered, so /run and /run-stream want a personal token from signing in. A guest attempting a run gets 403 FORBIDDEN, not a 402.

Watch the status code rather than the body when you wire this up. POST /guest is the only call on this API that answers 201, and a client that tests status === 200 before parsing will decide the mint failed while holding a perfectly good token. Treat any 2xx as success and read ok.

# The slug goes in the BODY, not in a header. This is the only call that needs it.
TOKEN=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"psm-desk"}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["token"])')

echo "${TOKEN:0:12}..."   # sk_guest_...

# Keep the whole object if you want guest_id and expires_at:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"psm-desk"}' | python3 -m json.tool
# {
#   "ok": true,
#   "data": { "token": "sk_guest_...", "guest_id": "gst_...",
#             "expires_at": "2026-08-27T09:14:02Z" }
# }
#
# 201 Created, not 200. A guest token can /me and /estimate; a metered /run
# needs a personal token from https://psm-desk.skillsafe.ai/tokens.html.

2. Check the session and the balance

GET /me returns three fields and nothing else:

{ "ok": true, "data": {
    "subject_type": "guest",
    "subject_id": "gst_...",
    "credits": 1200
} }

Read that literally, because the fields people expect are not there. There is no user_id — the identifier is subject_id whichever kind of subject it is, so a guest's subject_id is its guest_id and a signed-in person's is their user id. There is no is_guest flag, so a truthiness test on it is silently false for everybody, which reads as "this is a real user" for a guest token. Branch on subject_type, which is guest or user. There is no username, no email and no plan field either; if you need a display name, you need your own.

credits is the wallet balance. Compare it against min_credits from step 3 before you run, so a shortfall becomes your own clear message instead of a 402 in the middle of a batch of forty exports.

call me | data | python3 -m json.tool
# {
#     "subject_type": "user",
#     "subject_id": "usr_...",
#     "credits": 51234
# }

# Branch on subject_type. There is no is_guest field and no user_id field.
SUBJ=$(call me | data | python3 -c 'import sys,json; print(json.load(sys.stdin)["subject_type"])')
if [ "$SUBJ" = "guest" ]; then
  echo "guest token: /me and /estimate are fine, a metered /run will 403"
fi

# Balance, as a plain integer, for a shell gate before a batch:
CREDITS=$(call me | data | python3 -c 'import sys,json; print(json.load(sys.stdin)["credits"])')
echo "balance $CREDITS credits"

3. Price the run — free, but authenticated

POST /estimate creates no job and charges nothing. It is still an authenticated call, which is the ordering trap: it has to come after step 1. A caller who builds an input, prices it and only then goes looking for a token gets a 401 UNAUTHORIZED on the free call and reads it as a broken endpoint. Mint the token first, price second.

The body is the input object from above, flat. What comes back:

fieldtypemeaning
modelstringThe exact model the run will bind to. Read it from the reply and log it; do not hard-code it, because the concrete model moves.
model_aliasstringThe stable alias that binding came from — the thing to log, and the thing to assert on if you want to be certain you are talking to this app and not to a different one behind the same base URL.
markup_bpsintegerThe app's markup in basis points; 1000 is ten per cent.
hold_creditsinteger, varies by laneWhat gets reserved when the run starts. Priced against the full output cap, so it is an upper bound, not the price.
min_creditsinteger, varies by laneThe balance you must clear for the run to start at all. Compare this against credits from /me.
sponsor_enabledbooleanWhether the app is covering this run rather than your wallet.

hold_credits is a reservation and not the price. The charged_credits you see on the settled job is normally far lower, because an ident pass over a clean 400-row export says so in a few hundred tokens while the cap has to allow for a methods lane with a twenty-row reporting table, an eight-item deposition checklist and a drafted paragraph. Budget against hold_credits; report against charged_credits.

The hold differs per lane, and it differs a lot here. methods is the wordiest — it drafts prose and two tables. ident is the leanest on a clean run and the fattest on a broken one, since every failing check earns a finding with its own evidence string. quant sits between them and is the one most sensitive to the number of measurement columns. If you are running all three lanes over one export, price all three rather than multiplying the ident figure by three.

And psms moves the estimate more than anything else in the input: 150 rows of sixteen columns is a lot of text, every row is read, and a 40,000-row export sampled to 150 rows still prices several times a ten-row example. Price the real input, not a stub — a stub is the one way to be surprised by the hold on the run that matters.

# The two artefacts as shell variables, so the JSON stays readable. Use quoted
# heredocs ('EOF') so nothing inside gets expanded by the shell.
PARAMS=$(cat <<'EOF'
Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)
Instrument: Orbitrap Exploris 480
Acquisition: DDA, top-20, 90 min gradient
Search engine: MSFragger 4.0 (FragPipe 21.1)
Database: UniProt human reference UP000005640, 20428 entries, reversed decoys appended
Enzyme: trypsin, up to 2 missed cleavages
Fixed modifications: carbamidomethyl (C)
Variable modifications: oxidation (M), acetyl (protein N-term)
Precursor tolerance: 20 ppm
Fragment tolerance: 0.02 Da
FDR: 1%, controlled at the PSM level with Percolator q-values
Decoy strategy: reversed, concatenated
Protein inference: razor peptides, single-peptide identifications retained
Quantification: label-free, MS1 area (IonQuant)
Normalisation: none
Missing values: left as-is
Replicates: 4 per condition
Statistical test: two-sample t-test per protein
Multiple testing: none
Contaminants: cRAP appended, not removed from the export
Deposition: not yet deposited
Software versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10
EOF
)

# In real use this is your engine's export, read straight off disk:
#   PSMS=$(cat psm.csv)
PSMS=$(cat <<'EOF'
peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2
AAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8
LVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7
SGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6
VLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,
LKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6
MDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,
SLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,
ELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7
CDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,
TGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5
EOF
)

# Build the body with python3 so the newlines and commas are escaped correctly.
# NOTE the shape: the input object IS the body. No "input" wrapper.
INPUT=$(PARAMS="$PARAMS" PSMS="$PSMS" python3 -c '
import json, os
print(json.dumps({
  "task": "ident",
  "psms": os.environ["PSMS"],
  "params": os.environ["PARAMS"],
  "goal": "publication",
  "stage": "first_search",
  "question": "can I report the 6,214 protein groups as a 1% FDR protein list?",
}))')

call estimate "$INPUT" | data
# {"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#  "hold_credits":2480,"min_credits":300,"sponsor_enabled":false}
#
# Free, but authenticated: without the Authorization header this is a 401, not
# an anonymous price check. And it differs per lane - price the lane you will run.
for LANE in ident quant methods; do
  BODY=$(INPUT="$INPUT" LANE="$LANE" python3 -c '
import json, os
b = json.loads(os.environ["INPUT"]); b["task"] = os.environ["LANE"]
print(json.dumps(b))')
  printf '%-8s %s\n' "$LANE" "$(call estimate "$BODY" | data)"
done

The @params in the C# sample is not a typo: params is a C# keyword, so the property is written @params and serialises to the JSON name params, which is what the app reads. Every language has one of these — in Go it is the struct tag, in Ruby the symbol key, in PHP the array key. The JSON field name is always params.

4. Run it, poll, and check the reply

POST /run returns {job_id}; poll GET /job/{job_id} until status is succeeded or failed. The review is a JSON string at data.output.output — one object, the envelope described in the output contract. The terminal job also carries charged_credits, the real price, and truncated.

The body is the input object itself — again, and for the last time. This is the call where the wrapped-input mistake costs money: {"input": {…}} returns 200, takes the hold, runs, and bills you for a review of an empty object. The reply will be fluent and it will be about nothing — an identification review of a run with no decoys, no q-values and no protein groups. There is no error code and no warning; the only signal is that lane comes back as whatever the model guessed from an empty input, and every reconciliation entry you were promised is missing. Send it flat, and assert lane.

Always send an Idempotency-Key, and put the lane in it. Derive it from the input the way the web app does — a content hash plus the lane plus an attempt counter, psm-desk:<hash>:<lane>:a<attempt>. A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a retry safe after a network blip on an export you have already paid to review. Two lanes over one paste are two distinct runs, and they must not share a key: ident and quant over the same table are different questions with different outputs, and a key reused across them either replays the wrong job or is rejected outright. The lane is part of the body, so it belongs in the key. Bump the attempt suffix whenever the input actually changed.

The polling interval that works here is one second with a cap: an ident pass over a short export settles in a few seconds, and a methods lane drafting a paragraph and two tables against 150 rows can take most of a minute. Poll on a fixed short interval rather than an exponential backoff — the job is not rate-limited and a backoff mostly adds latency to the common case.

# Always send an Idempotency-Key derived from the input, WITH THE LANE IN IT.
# A retried request with the same key returns the SAME job instead of billing twice.
LANE=ident
KEY="psm-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):$LANE:a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["job_id"])')

echo "job $JOB"

# Poll on a fixed one-second interval, with a ceiling.
for i in $(seq 1 120); do
  RES=$(curl -sS "$BASE/job/$JOB" -H "Authorization: Bearer $TOKEN")
  STATUS=$(printf '%s' "$RES" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["status"])')
  case "$STATUS" in
    succeeded|failed) break ;;
  esac
  sleep 1
done

# Pull the review out of data.output.output and run the assertions.
printf '%s' "$RES" | TASK="$LANE" ROWS_SENT=10 python3 -c '
import json, os, sys

LANE_BODY_KEYS = {
  "ident":   {"fdr_assessment", "evidence_review", "drop_list", "trust_scope"},
  "quant":   {"quant_readiness", "normalisation", "missingness", "design_review",
              "changes", "min_reportable"},
  "methods": {"methods_paragraph", "reporting_table", "deposition_checklist",
              "limitations", "open_items"},
}
VERDICTS = {"reportable", "reportable_with_caveats", "revise", "not_reportable",
            "unassessable"}
SEVERITIES = {"blocking", "high", "medium", "low", "info"}
AREAS = {"fdr", "digestion", "calibration", "modification", "inference",
         "contamination", "quantitation", "design", "reporting"}

job = json.load(sys.stdin)["data"]
if job["status"] != "succeeded":
    raise SystemExit("job %s: %s" % (job["status"], job.get("error")))
if job.get("truncated"):
    raise SystemExit("truncated: retry, do not repair")

raw = job["output"]["output"]
review = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])

assert review["lane"] == os.environ["TASK"], (review["lane"], "wrapped input?")
assert set(review["body"]) <= LANE_BODY_KEYS[review["lane"]], "body keys from another lane"
assert review["verdict"] in VERDICTS, review["verdict"]

rows_sent = int(os.environ["ROWS_SENT"])
worst = set()
for f in review["findings"]:
    assert f["severity"] in SEVERITIES and f["area"] in AREAS, f["id"]
    assert f["row"] is None or 1 <= f["row"] <= rows_sent, (f["id"], f["row"])
    worst.add(f["severity"])
if review["verdict"] == "not_reportable": assert "blocking" in worst
if review["verdict"] == "reportable":     assert not (worst - {"info"})

print(review["verdict"], "|", review["headline"])
print("charged", job["charged_credits"], "credits")
'

The terminal job, for reference — output.output is the string those samples slice:

{ "ok": true, "data": {
    "job_id": "job_01J9Z...",
    "status": "succeeded",
    "truncated": false,
    "charged_credits": 1180,
    "output": {
      "output": "{\"lane\":\"ident\",\"title\":\"HeLa staurosporine DDA - first search...\"}"
    }
} }

Two details in those samples that are easy to skip. findings[].row is checked against the number of rows you actually sent, not against the row count of your original export — the model can only see the sample, so a row index beyond it is fabricated by definition. And the Idempotency-Key hash is computed over the whole body including prescan_facts, so adding a flag changes the key: that is correct, because a run held to one more fact is a different run.

5. Or stream it

POST /run-stream is the same call over server-sent events, with Accept: text/event-stream added to the same headers and the same Idempotency-Key. The events are job ({job_id}, first), delta ({"text": "..."}, a chunk of the review JSON), done (status, charged_credits, truncated) and error on a failure. Two practical details: an idempotent replay of a key that already ran comes back as plain JSON rather than a stream, so check the response Content-Type before you start reading lines; and events are separated by a blank line, so split on \n\n rather than assuming one data: line per event.

For a progress display, do not parse the partial JSON — watch for key names arriving in the accumulating text. "findings" means the review is naming problems, "reconciliation" means it has reached your prescan flags, "body" means the lane's own document has started, and the lane's last block means it is nearly done: "trust_scope" for ident, "min_reportable" for quant, "open_items" for methods. Substring matching on the quoted key name is enough and it costs nothing.

That ordering is also the reason a truncated stream is dangerous rather than merely incomplete. body is the last key in the envelope, so a cut-off ident reply is a list of findings with no trust_scope — the block that says what may not be reported is exactly the part that goes missing. Read truncated off the done event before you render anything.

# Same body, same Idempotency-Key, one extra header.
curl -sSN -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT"

# event: job
# data: {"job_id":"job_..."}
#
# event: delta
# data: {"text":"{\"lane\":\"ident\",\"title\":\"HeLa staurosporine DDA"}
#
# event: delta
# data: {"text":"\",\"verdict\":\"not_reportable\",\"headline\":\"The decoys"}
#
# event: done
# data: {"status":"succeeded","charged_credits":1180,"truncated":false}

# -N disables curl's buffering; without it the deltas arrive in one lump at the
# end and the stream was pointless. Reassemble the review from the delta texts:
curl -sSN -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" -H "Idempotency-Key: $KEY" -d "$INPUT" \
| python3 -c '
import json, sys
text = ""
seen = set()
for line in sys.stdin:
    if not line.startswith("data:"):
        continue
    payload = json.loads(line[5:].strip() or "{}")
    if "text" in payload:
        text += payload["text"]
        for key in ("findings", "reconciliation", "body", "trust_scope"):
            if ("\"%s\"" % key) in text and key not in seen:
                seen.add(key)
                print("...", key, file=sys.stderr)
    elif payload.get("status"):
        if payload.get("truncated"):
            raise SystemExit("truncated: retry, do not repair")
review = json.loads(text[text.index("{"):text.rindex("}") + 1])
print(review["verdict"], "|", review["headline"])
'

One thing the stream does not give you that the poll does: charged_credits arrives only on the done event, so a client that stops reading as soon as the JSON closes never learns the price. Read to the end of the stream even when you already have a parseable review.

6. One worked request per lane

Three bodies, one per lane, each a complete request you can POST as-is, followed by a trimmed but structurally complete reply. They are deliberately small: a real psms is a sample of about 150 rows drawn from tens of thousands, and a real params is the whole workflow file. The only things that change between these three are task, goal, stage and the question being asked. The prescan_facts are trimmed here to keep the examples readable; in the browser they carry every flag the free read raised.

ident — are these identifications reportable?

The lane to run first, and the one worth gating on: if the error rate does not hold, nothing downstream is worth reading. It is the only lane that recomputes the target-decoy arithmetic, so this is the request where a decoy column and a q_value column earn their place.

{
  "task": "ident",
  "psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2\nAAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8\nLVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7\nSGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6\nVLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,\nLKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6\nMDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,\nSLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,\nELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7\nCDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,\nTGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5\n",
  "params": "Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)\nInstrument: Orbitrap Exploris 480\nAcquisition: DDA, top-20, 90 min gradient\nSearch engine: MSFragger 4.0 (FragPipe 21.1)\nDatabase: UniProt human reference UP000005640, 20428 entries, reversed decoys appended\nEnzyme: trypsin, up to 2 missed cleavages\nFixed modifications: carbamidomethyl (C)\nVariable modifications: oxidation (M), acetyl (protein N-term)\nPrecursor tolerance: 20 ppm\nFragment tolerance: 0.02 Da\nFDR: 1%, controlled at the PSM level with Percolator q-values\nDecoy strategy: reversed, concatenated\nProtein inference: razor peptides, single-peptide identifications retained\nQuantification: label-free, MS1 area (IonQuant)\nNormalisation: none\nMissing values: left as-is\nReplicates: 4 per condition\nStatistical test: two-sample t-test per protein\nMultiple testing: none\nContaminants: cRAP appended, not removed from the export\nDeposition: not yet deposited\nSoftware versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10\n",
  "goal": "publication",
  "stage": "first_search",
  "question": "we need to submit this week; can I report the 6,214 protein groups as a 1% FDR protein list?",
  "prescan_facts": {
    "table": { "rows_total": 41207, "rows_sent": 10, "sampled": true,
               "quant_columns": 4, "protein_groups": 6214, "distinct_peptides": 28911 },
    "settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
                  "q_threshold": 0.01, "fdr_level": "psm", "decoy_search": "concatenated",
                  "missed_allowed": 2, "min_peptides": 1, "replicates": 4,
                  "quant_method": "label-free (LFQ)" },
    "fdr": { "decoy_source": "column", "strategy": "concatenated",
             "claimed_threshold": 0.01, "claimed_level": "psm",
             "at_threshold": { "targets": 40101, "decoys": 352, "concatenated": 0.017403 },
             "rows_above_threshold": 754, "decoys_above_threshold": 604,
             "decoy_status_unresolved": 0, "max_q": 0.191 },
    "verdict": "not_reportable",
    "severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
    "flags": [
      { "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
        "mitigated_by": [], "row": null, "line": 11,
        "title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
        "evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
        "value": 0.017403, "threshold": 0.01 },
      { "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 11,
        "title": "Protein-level claims from a PSM-level error rate",
        "evidence": "6214 protein groups, FDR stated at the PSM level",
        "value": 6214, "threshold": null },
      { "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
        "mitigated_by": [], "row": null, "line": 6,
        "title": "Just under a third of peptides carry at least one missed cleavage",
        "evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
        "value": 0.31, "threshold": null },
      { "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 13,
        "title": "Nearly a quarter of protein groups rest on a single peptide",
        "evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
        "value": 0.2387, "threshold": null },
      { "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 15,
        "title": "Label-free intensities with normalisation stated as none",
        "evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
        "value": null, "threshold": null }
    ],
    "unassessable": [
      { "item": "the transferred-identification error rate",
        "why": "match-between-runs is not mentioned in the parameters" }
    ]
  }
}

The reply to that request is written out in full above, which is why it is not repeated here. The two things to look at in it are the five reconciliation entries against the five uids — one of which is not_applicable, which is an answer and not a dodge — and trust_scope, which is the block that answers the question a caller actually has: what may I say?

quant — can the intensities carry a claim?

Run this when ident holds, or when it does not and you want to know what else is wrong before deciding whether to re-search. The input is the same table and the same parameters — the measurement columns were always there — with goal and the question changed to the decision being made. Note that the prescan_facts here would carry the quantitation block and the design flags; the trimmed version below keeps the two flags the lane turns on.

{
  "task": "quant",
  "psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2\nAAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8\nLVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7\nSGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6\nVLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,\nLKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6\nMDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,\nSLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,\nELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7\nCDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,\nTGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5\n",
  "params": "Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)\nInstrument: Orbitrap Exploris 480\nAcquisition: DDA, top-20, 90 min gradient\nSearch engine: MSFragger 4.0 (FragPipe 21.1)\nDatabase: UniProt human reference UP000005640, 20428 entries, reversed decoys appended\nEnzyme: trypsin, up to 2 missed cleavages\nFixed modifications: carbamidomethyl (C)\nVariable modifications: oxidation (M), acetyl (protein N-term)\nPrecursor tolerance: 20 ppm\nFragment tolerance: 0.02 Da\nFDR: 1%, controlled at the PSM level with Percolator q-values\nDecoy strategy: reversed, concatenated\nProtein inference: razor peptides, single-peptide identifications retained\nQuantification: label-free, MS1 area (IonQuant)\nNormalisation: none\nMissing values: left as-is\nReplicates: 4 per condition\nStatistical test: two-sample t-test per protein\nMultiple testing: none\nContaminants: cRAP appended, not removed from the export\nDeposition: not yet deposited\nSoftware versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10\n",
  "goal": "decision",
  "stage": "final_check",
  "question": "the DMSO/staurosporine fold changes are going into a figure this week - which of them can I actually show?",
  "prescan_facts": {
    "table": { "rows_total": 41207, "rows_sent": 10, "sampled": true,
               "quant_columns": 4, "protein_groups": 6214, "distinct_peptides": 28911 },
    "settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
                  "q_threshold": 0.01, "fdr_level": "psm", "decoy_search": "concatenated",
                  "missed_allowed": 2, "min_peptides": 1, "replicates": 4,
                  "quant_method": "label-free (LFQ)" },
    "fdr": { "decoy_source": "column", "strategy": "concatenated",
             "claimed_threshold": 0.01, "claimed_level": "psm",
             "at_threshold": { "targets": 40101, "decoys": 352, "concatenated": 0.017403 },
             "rows_above_threshold": 754, "decoys_above_threshold": 604,
             "decoy_status_unresolved": 0, "max_q": 0.191 },
    "verdict": "not_reportable",
    "severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
    "flags": [
      { "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
        "mitigated_by": [], "row": null, "line": 11,
        "title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
        "evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
        "value": 0.017403, "threshold": 0.01 },
      { "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 11,
        "title": "Protein-level claims from a PSM-level error rate",
        "evidence": "6214 protein groups, FDR stated at the PSM level",
        "value": 6214, "threshold": null },
      { "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
        "mitigated_by": [], "row": null, "line": 6,
        "title": "Just under a third of peptides carry at least one missed cleavage",
        "evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
        "value": 0.31, "threshold": null },
      { "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 13,
        "title": "Nearly a quarter of protein groups rest on a single peptide",
        "evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
        "value": 0.2387, "threshold": null },
      { "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 15,
        "title": "Label-free intensities with normalisation stated as none",
        "evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
        "value": null, "threshold": null }
    ],
    "unassessable": [
      { "item": "the transferred-identification error rate",
        "why": "match-between-runs is not mentioned in the parameters" }
    ]
  }
}

An abridged quant reply. The envelope keys are identical; only body differs:

{
  "lane": "quant",
  "title": "HeLa staurosporine DDA - quantification readiness, 4 label-free columns",
  "verdict": "not_reportable",
  "headline": "Four unnormalised label-free columns whose medians span 28%, 38% of rows incomplete with no missing-value policy, and 6,214 per-protein t-tests with no correction: no individual fold change from this export is reportable.",
  "summary": "The measurement side of this export is four MS1 intensity columns, one per replicate, which is enough to compare two conditions in principle. Three things stop it in practice, and all three are fixable on this same file without new instrument time. The columns were not normalised and their medians differ by 28%, so part of every ratio is loading. Missingness is 38% overall and rises monotonically from DMSO_1 to STS_2, which is the signature of a treatment that lowered intensity rather than of random dropout - and with values left as-is, each t-test silently ran on whatever subset was present. And 6,214 uncorrected per-protein tests produce roughly 310 significant results at p < 0.05 from noise alone. Fix the three and a fold-change table with q-values is reportable for the proteins measured in at least three of four replicates per condition.",
  "findings": [
    { "id": "F-001", "severity": "blocking", "area": "design",
      "title": "6,214 per-protein t-tests with the correction stated as none",
      "detail": "The parameters state a two-sample t-test per protein and multiple testing as none. At an uncorrected 0.05 threshold, 6,214 tests yield about 310 false positives, which is the same order as the number of true changes a six-hour kinase-inhibitor treatment typically produces at this depth. Any list of significant proteins from this analysis is therefore mostly unordered with respect to truth. This is blocking rather than high because it is not a caveat on the result - it is the result being indistinguishable from noise at the list level.",
      "evidence": "parameters line 19: \"Multiple testing: none\"; 6,214 protein groups",
      "row": null,
      "fix": "Apply Benjamini-Hochberg across the per-protein p-values and report q-values. It is a one-line change and it runs on the numbers you already have." },
    { "id": "F-002", "severity": "high", "area": "quantitation",
      "title": "Label-free intensities with normalisation stated as none",
      "detail": "Column medians are 1.24e6, 1.19e6, 1.01e6 and 9.68e5 - a 28% spread between the extremes, and the two lower ones are both staurosporine columns. That is exactly the pattern that a median normalisation exists to remove, and leaving it in means a systematic downward shift is being read as biology in every ratio.",
      "evidence": "parameters line 15: \"Normalisation: none\"; per-column medians from the 4 intensity columns",
      "row": null,
      "fix": "Median- or quantile-normalise the four columns before any ratio is taken, and state which in the methods." },
    { "id": "F-003", "severity": "high", "area": "quantitation",
      "title": "38% of rows are incomplete and the missingness is not random",
      "detail": "25,548 of 41,207 rows carry a value in all four columns. The missing share rises from 26.9% (DMSO_1) to 32.1% (STS_2) monotonically across the treatment axis, so this is intensity-dependent dropout and not random. With values left as-is, a protein present in four DMSO columns and one staurosporine column produces a fold change from one measurement against four.",
      "evidence": "complete_share 0.6199; per-column missing shares 0.2694, 0.2749, 0.3024, 0.3207",
      "row": null,
      "fix": "Require a value in at least three of four replicates per condition, and state the rule. Impute only if you can defend the assumption, and say which method - left-censored and random imputation are different claims." },
    { "id": "F-004", "severity": "medium", "area": "contamination",
      "title": "Contaminant intensity is inside the totals",
      "detail": "1,146 contaminant rows, 611 of them keratin, are still in the export. Keratin intensity tracks sample handling rather than treatment, and it is part of the column total any normalisation would be computed against, so the contaminants affect the ratios of everything else and not only their own rows.",
      "evidence": "1,146 rows over 4 cRAP classes; parameters line 20 says they were not removed",
      "row": 3,
      "fix": "Remove contaminant rows before normalising, not after." },
    { "id": "F-005", "severity": "medium", "area": "design",
      "title": "Replicate kind is not stated",
      "detail": "Four replicates per condition are stated and four columns are present, so there is one measurement per replicate and no technical repeat inside any of them. Whether those four are biological or technical decides what the fold change generalises to, and the parameters do not say.",
      "evidence": "parameters line 17: \"Replicates: 4 per condition\"; 4 intensity columns",
      "row": null,
      "fix": "State it. If they are technical, the comparison is about these two flasks and the wording has to say so." },
    { "id": "F-006", "severity": "low", "area": "quantitation",
      "title": "Match-between-runs is not mentioned",
      "detail": "IonQuant is named and match-between-runs is not, so whether intensities were transferred between injections cannot be established. It matters here because transfer is the usual remedy for exactly the missingness pattern in F-003, and a transferred value is not covered by the search-level FDR.",
      "evidence": "parameters line 14 names IonQuant; no match-between-runs statement anywhere",
      "row": null,
      "fix": "State whether it was on. If it was, report the transfer FDR alongside the search FDR." }
  ],
  "reconciliation": [
    { "flag_uid": "P01", "status": "noted",
      "note": "Carried forward rather than re-judged: the 1.74% empirical FDR is an identification fact and this lane does not re-litigate it. It does bound this lane's output, so it is in caveats - a fold change between two lists at 1.74% is a fold change between two lists of that purity." },
    { "flag_uid": "P02", "status": "noted",
      "note": "Same: a quantitative comparison across 6,214 groups whose protein-level error is unbounded inherits that gap, and it compounds F-001 rather than replacing it." },
    { "flag_uid": "P04", "status": "adjusted",
      "note": "Adjusted upward in relevance for this lane. In the ident lane a 31% missed-cleavage share costs depth; here it also splits one peptide's signal across its missed-cleavage forms, so the MS1 areas being compared are systematically low and unevenly so." },
    { "flag_uid": "P06", "status": "confirmed",
      "note": "Confirmed, with a quantitative consequence the identification reading does not state: a one-peptide group's intensity is one peptide's intensity, so 1,483 of the 6,214 fold changes have no internal replication at the peptide level at all." },
    { "flag_uid": "P08", "status": "confirmed",
      "note": "Confirmed at high, and it is F-002. The flag's evidence and this reply's evidence are the same four column medians." }
  ],
  "caveats": [
    { "area": "fdr",
      "fact": "The exported list's own decoys correspond to 1.74% at the stated q <= 0.01, not 1%.",
      "why_it_matters": "Every fold change is a ratio between two lists of that purity, so a change on a single peptide near the threshold may be a change in a wrong identification. It argues for restricting the figure to multi-peptide proteins." },
    { "area": "inference",
      "fact": "1,483 of 6,214 groups rest on one peptide.",
      "why_it_matters": "Those fold changes have no peptide-level replication, so they should not appear in a figure of top changers even after the three fixes above." }
  ],
  "context_notes": [
    { "claim": "the DMSO/staurosporine fold changes are going into a figure this week - which of them can I actually show?",
      "status": "contradicted",
      "note": "None of them, as the data stand - not because the experiment is bad but because three unapplied steps are between the intensities and a ratio that means anything. All three run on this file in an afternoon, which fits the week: normalise, filter for completeness, correct for multiplicity. After that, show the proteins with three or more values per condition and two or more peptides, with q-values." }
  ],
  "unassessable": [
    { "item": "batch structure",
      "why": "The parameters name no acquisition order, no batch and no date, so whether the two conditions were run interleaved or in blocks cannot be established - and that decides whether the 28% median spread is loading or drift." },
    { "item": "the transferred-identification error rate",
      "why": "Match-between-runs is not mentioned, so no transfer FDR can be checked." },
    { "item": "whether the four replicates are biological or technical",
      "why": "Stated only as \"4 per condition\"." }
  ],
  "body": {
    "quant_readiness": "Four label-free MS1 intensity columns, one per replicate, two conditions - a design that supports a two-group comparison. What is missing is everything between the raw areas and a ratio: no normalisation, no missing-value policy, no multiple-testing correction, and contaminants still in the totals. None of that requires new instrument time; all of it changes the answer. As it stands the export supports a qualitative depth statement and no individual fold change.",
    "normalisation": {
      "method_as_stated": "none",
      "judgement": "fail",
      "note": "Required here and absent — Medians 1.24e6, 1.19e6, 1.01e6, 9.68e5 across DMSO_1, DMSO_2, STS_1, STS_2 - a 28% spread with both lower values on the treatment side. Median normalisation is the minimum; a variance-stabilising transform would be defensible given the 18.5-18.8 log2 dynamic range per column."
    },
    "missingness": {
      "judgement": "fail",
      "note": "High and intensity-dependent — 25,548 of 41,207 rows complete across all four columns (62.0%). The per-column missing share rises monotonically 26.9%, 27.5%, 30.2%, 32.1% along the treatment axis, which is dropout at the low-intensity end rather than random loss. Values were left as-is, so every test ran on a different subset and no test's degrees of freedom are what a reader would assume."
    },
    "design_review": [
      { "item": "replication",
        "judgement": "concern",
        "note": "Adequate in number, unstated in kind — 4 per condition, one measurement column each. Biological or technical is not stated and it decides what the result generalises to." },
      { "item": "statistical test",
        "judgement": "fail",
        "note": "Named, and unusable as configured — Two-sample t-test per protein over 6,214 proteins with correction stated as none: about 310 expected false positives at p < 0.05." },
      { "item": "contaminants",
        "judgement": "concern",
        "note": "Left in, and they carry intensity — 1,146 rows, 611 keratin. Inside the normalisation total, so they affect every other protein's ratio too." },
      { "item": "batch structure",
        "judgement": "unassessable",
        "note": "Not established — No acquisition order or date in the parameters, so drift cannot be separated from loading." }
    ],
    "changes": [
      { "change": "median-normalise the four intensity columns, after removing contaminant rows",
        "why": "Removes the 28% loading difference that is currently inside every ratio, and keeps keratin out of the total it is computed against.",
        "effort": "cheap" },
      { "change": "require a value in at least 3 of 4 replicates per condition before testing",
        "why": "Stops a one-against-four comparison being reported as a fold change.",
        "effort": "cheap" },
      { "change": "apply Benjamini-Hochberg across the per-protein tests and report q-values",
        "why": "6,214 uncorrected tests produce hundreds of false positives at any threshold worth quoting.",
        "effort": "cheap" },
      { "change": "restrict the figure to proteins with two or more peptides",
        "why": "A one-peptide group's fold change has no peptide-level replication behind it.",
        "effort": "cheap" },
      { "change": "state the replicate kind and the acquisition order, or record them for the next run",
        "why": "Decides what the comparison generalises to and whether drift is separable from loading.",
        "effort": "moderate" },
        { "change": "re-acquire the affected conditions with a randomised injection order",
          "why": "Drift and loading are confounded in this export and no reprocessing can separate them.",
          "effort": "expensive" }
    ],
    "min_reportable": "Without any of the changes: that 6,214 protein groups were identified across eight injections and MS1 areas were recorded for 62% of rows in all four channels - a depth statement, not a comparison. With the four cheap changes, all of which run on this export: a fold-change table with Benjamini-Hochberg q-values for the proteins that have two or more peptides and a value in at least three of four replicates per condition, described as label-free MS1 area with median normalisation and no imputation."
  }
}

methods — write it up and deposit it

Run this last. The input is the same again, with goal set to what the text is for — repository_deposition here, which is what turns the deposition checklist from a courtesy into the leading block — and the question set to what you are actually writing.

{
  "task": "methods",
  "psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2\nAAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8\nLVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7\nSGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6\nVLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,\nLKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6\nMDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,\nSLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,\nELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7\nCDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,\nTGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5\n",
  "params": "Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)\nInstrument: Orbitrap Exploris 480\nAcquisition: DDA, top-20, 90 min gradient\nSearch engine: MSFragger 4.0 (FragPipe 21.1)\nDatabase: UniProt human reference UP000005640, 20428 entries, reversed decoys appended\nEnzyme: trypsin, up to 2 missed cleavages\nFixed modifications: carbamidomethyl (C)\nVariable modifications: oxidation (M), acetyl (protein N-term)\nPrecursor tolerance: 20 ppm\nFragment tolerance: 0.02 Da\nFDR: 1%, controlled at the PSM level with Percolator q-values\nDecoy strategy: reversed, concatenated\nProtein inference: razor peptides, single-peptide identifications retained\nQuantification: label-free, MS1 area (IonQuant)\nNormalisation: none\nMissing values: left as-is\nReplicates: 4 per condition\nStatistical test: two-sample t-test per protein\nMultiple testing: none\nContaminants: cRAP appended, not removed from the export\nDeposition: not yet deposited\nSoftware versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10\n",
  "goal": "repository_deposition",
  "stage": "final_check",
  "question": "drafting the methods section and starting a PRIDE submission - what do I still need before either can be finished?",
  "prescan_facts": {
    "table": { "rows_total": 41207, "rows_sent": 10, "sampled": true,
               "quant_columns": 4, "protein_groups": 6214, "distinct_peptides": 28911 },
    "settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
                  "q_threshold": 0.01, "fdr_level": "psm", "decoy_search": "concatenated",
                  "missed_allowed": 2, "min_peptides": 1, "replicates": 4,
                  "quant_method": "label-free (LFQ)" },
    "fdr": { "decoy_source": "column", "strategy": "concatenated",
             "claimed_threshold": 0.01, "claimed_level": "psm",
             "at_threshold": { "targets": 40101, "decoys": 352, "concatenated": 0.017403 },
             "rows_above_threshold": 754, "decoys_above_threshold": 604,
             "decoy_status_unresolved": 0, "max_q": 0.191 },
    "verdict": "not_reportable",
    "severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
    "flags": [
      { "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
        "mitigated_by": [], "row": null, "line": 11,
        "title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
        "evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
        "value": 0.017403, "threshold": 0.01 },
      { "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 11,
        "title": "Protein-level claims from a PSM-level error rate",
        "evidence": "6214 protein groups, FDR stated at the PSM level",
        "value": 6214, "threshold": null },
      { "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
        "mitigated_by": [], "row": null, "line": 6,
        "title": "Just under a third of peptides carry at least one missed cleavage",
        "evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
        "value": 0.31, "threshold": null },
      { "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 13,
        "title": "Nearly a quarter of protein groups rest on a single peptide",
        "evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
        "value": 0.2387, "threshold": null },
      { "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
        "mitigated_by": [], "row": null, "line": 15,
        "title": "Label-free intensities with normalisation stated as none",
        "evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
        "value": null, "threshold": null }
    ],
    "unassessable": [
      { "item": "the transferred-identification error rate",
        "why": "match-between-runs is not mentioned in the parameters" }
    ]
  }
}

An abridged methods reply, with body shortened to the shape rather than the full text (the full body for this lane is written out above):

{
  "lane": "methods",
  "title": "HeLa staurosporine DDA - methods draft and deposition checklist",
  "verdict": "revise",
  "headline": "The methods paragraph can be written today from what the parameters state, but it has to quote the empirical 1.74% rather than the nominal 1%, and the submission is blocked on a repository accession and a statistical analysis record.",
  "summary": "Twenty-one of the twenty-one items a reader needs are either stated or computable from this input, which is why a full paragraph is drafted below rather than a template with gaps. Three of them are not established and are written as omissions rather than filled in from convention: the LC gradient, the fragment-tolerance rationale and the rescoring configuration. The verdict is revise rather than reportable_with_caveats for one reason: the paragraph as it would naturally be written says 1% FDR, and the export's own decoys say 1.74%, so the sentence has to change before it goes anywhere. On the deposition side, two items are missing outright - the accession and the statistical analysis record - and two need a number rather than a description.",
  "findings": [
    { "id": "F-001", "severity": "high", "area": "reporting",
      "title": "The nominal threshold and the empirical rate disagree, and the paragraph must not quote the nominal one alone",
      "detail": "The parameters state 1% at the PSM level. The export's surviving decoys give 1.74% under the concatenated estimator the same parameters imply. A methods paragraph that states 1% is not describing this file. This is high rather than blocking because the fix is a sentence: state both, or re-filter and re-count.",
      "evidence": "parameters line 11 against 352 decoys among 40,453 rows at q <= 0.01",
      "row": null,
      "fix": "Write: filtered to a nominal 1% PSM-level FDR; the decoys surviving in the exported list correspond to 1.74% by the concatenated-database estimator." },
    { "id": "F-002", "severity": "medium", "area": "reporting",
      "title": "No repository accession, and the dataset is not deposited",
      "detail": "Stated as not yet deposited. Every journal in the field requires an accession and PRIDE issues one at submission, before review, so this is the item with the longest lead time and the least work in it.",
      "evidence": "parameters line 21: \"Deposition: not yet deposited\"",
      "row": null,
      "fix": "Open the PRIDE submission now; quote the PXD accession in the paragraph as soon as it exists." },
    { "id": "F-003", "severity": "medium", "area": "reporting",
      "title": "The statistical analysis has no recorded parameters",
      "detail": "A two-sample t-test per protein is named with no software, no version, no script and no correction. That is not reproducible, and it is also the analysis the quant lane found unusable, so the record and the fix are the same piece of work.",
      "evidence": "parameters lines 18-19 name the test and \"Multiple testing: none\"",
      "row": null,
      "fix": "Record the script or the tool and version, and the correction once it is applied." },
    { "id": "F-004", "severity": "low", "area": "reporting",
      "title": "Three items are not established and are omitted rather than assumed",
      "detail": "The LC gradient is given as \"90 min\" with no composition, the fragment tolerance has no stated rationale, and rescoring is never mentioned although Percolator q-values are, which implies it. Each is written as an omission in the draft. Convention would supply plausible values for all three and that is precisely what this lane will not do.",
      "evidence": "coverage.never_mentioned includes rescoring, fragment_tolerance detail and gradient composition",
      "row": null,
      "fix": "Add the gradient composition, one line on the fragment tolerance, and the rescoring step - each is one sentence you already know." }
  ],
  "reconciliation": [
    { "flag_uid": "P01", "status": "confirmed",
      "note": "Confirmed, and in this lane it is a wording obligation rather than a re-analysis: the paragraph quotes 1.74% alongside the nominal threshold." },
    { "flag_uid": "P02", "status": "confirmed",
      "note": "Confirmed. The reporting-table row for the FDR names the level explicitly, and limitations carries the consequence for the group count." },
    { "flag_uid": "P04", "status": "noted",
      "note": "Noted and reported as a computed number in the reporting table. Nothing to write differently; a reader needs it to interpret the depth." },
    { "flag_uid": "P06", "status": "confirmed",
      "note": "Confirmed, and it appears twice by design: as a computed reporting-table row and as a limitation sentence." },
    { "flag_uid": "P08", "status": "confirmed",
      "note": "Confirmed as a reported row - normalisation: none - which is exactly what a repository wants recorded even though it is a finding elsewhere." }
  ],
  "caveats": [
    { "area": "quantitation",
      "fact": "No normalisation, no missing-value policy and no multiple-testing correction were applied.",
      "why_it_matters": "The drafted paragraph therefore reports no fold changes and no significance, and says so. If the quant lane's changes are applied, this paragraph needs a new quantification sentence." }
  ],
  "context_notes": [
    { "claim": "drafting the methods section and starting a PRIDE submission - what do I still need before either can be finished?",
      "status": "honoured",
      "note": "Both answered separately: the paragraph is drafted with three omissions marked, and the deposition checklist lists two missing items and two that need a number. The accession is the long-lead one; start it first." }
  ],
  "unassessable": [
    { "item": "the raw file list",
      "why": "The parameters describe the design but name no files, so the submission's file manifest cannot be drafted from this input." },
    { "item": "the exact database release",
      "why": "UP000005640 with 20,428 entries is stated; the release date or checksum that makes it reproducible is not." }
  ],
  "body": {
    "methods_paragraph": "Peptides were identified with MSFragger 4.0 (FragPipe 21.1) against the UniProt human reference proteome (UP000005640, 20,428 entries) with reversed decoys appended to a single concatenated database ... [full text as documented above] ... The LC gradient composition, the fragment-tolerance rationale and the rescoring configuration are not established by the available parameters and are omitted rather than assumed.",
    "reporting_table": [
      { "item": "Instrument", "value": "Orbitrap Exploris 480", "source": "reported" },
      { "item": "Empirical FDR in the exported list", "value": "1.74% at q <= 0.01 by 2D/(T+D)", "source": "computed" },
      { "item": "LC gradient composition", "value": "not reported", "source": "not_established" }
    ],
    "deposition_checklist": [
      { "item": "Repository accession", "status": "missing",
        "note": "Stated as not yet deposited; PRIDE issues it at submission." },
      { "item": "Exact database version string", "status": "needs_a_number",
        "note": "Entry count is given; the release date or FASTA checksum is what makes it reproducible." },
      { "item": "Decoy strategy statement", "status": "present",
        "note": "Reversed, concatenated - and it is the convention the empirical rate was computed under." }
    ],
    "limitations": [
      "The exported list is filtered at a nominal 1% PSM-level FDR and its own surviving decoys correspond to 1.74%.",
      "1,483 of 6,214 protein groups rest on a single peptide."
    ],
    "open_items": [
      "Open a PRIDE submission and obtain the accession.",
      "Re-filter and re-count at the stated threshold, or state 1.74% in the text."
    ]
  }
}

Running all three lanes over one export

The normal shape of an API integration: price all three, run them in lane order, and stop when ident comes back not_reportable — because a methods paragraph written over a list that cannot be reported is a paragraph nobody can use. Note the Idempotency-Key changing with the lane, which is what keeps the three runs distinct.

# Price all three, then run in lane order, stopping if ident blocks.
HASH=$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16)

for LANE in ident quant methods; do
  BODY=$(INPUT="$INPUT" LANE="$LANE" python3 -c '
import json, os
b = json.loads(os.environ["INPUT"]); b["task"] = os.environ["LANE"]
print(json.dumps(b))')

  # The lane is in the key: two lanes over one export are two runs.
  KEY="psm-desk:$HASH:$LANE:a1"

  JOB=$(curl -sS -X POST "$BASE/run" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $KEY" \
    -d "$BODY" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["job_id"])')

  for i in $(seq 1 240); do
    RES=$(curl -sS "$BASE/job/$JOB" -H "Authorization: Bearer $TOKEN")
    STATUS=$(printf '%s' "$RES" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["status"])')
    case "$STATUS" in succeeded|failed) break ;; esac
    sleep 1
  done

  VERDICT=$(printf '%s' "$RES" | python3 -c '
import json, sys
job = json.load(sys.stdin)["data"]
raw = job["output"]["output"]
r = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print("%s\t%s\t%s" % (r["lane"], r["verdict"], job["charged_credits"]))')
  printf '%s\n' "$VERDICT"

  case "$VERDICT" in
    ident*not_reportable*)
      echo "ident blocks - stopping before quant and methods"
      break ;;
  esac
done

The cross-lane check at the bottom of those samples is the one assertion that is not about a single reply. One fact must be reported at the same severity in all three lanes: the lane decides where a fact lands, never how bad it is. If fdr is blocking in ident and medium in methods, one of the two is wrong and the pair is worth failing on — it is also the cheapest possible detector for a run that silently received a different input than you think it did.

What the browser does before you pay

Everything described so far costs credits. The web app does a substantial amount of work before it asks for any, in the page, with no model call and no network request, and an API caller can either reproduce it or simply send its output as prescan_facts and let the lane be held to it. It is worth understanding either way, because it is what makes the reconciliation contract more than a formality: these are facts, established by arithmetic and string comparison, that a model cannot argue with.

Thirty reporting items, in three states

The free read resolves thirty items against the parameter block — the instrument, the acquisition mode, the gradient, the sample, the search engine, the database and its entry count, the enzyme, the missed cleavages, the fixed and variable modifications, the precursor and fragment tolerances, the FDR method, the decoy strategy, the threshold, the level, the minimum peptides per protein, the inference method, the rescoring, the quantification method, the normalisation, the missing-value handling, the replicates, the statistical test, the multiple-testing correction, match-between-runs, the contaminant handling, the deposition and the software versions. Each lands in exactly one of three states, and the distinction between the last two is the point of the whole exercise:

statemeanswhere it goes
statedThe parameters give a value. It is parsed, kept, and cross-checked against everything else that touches it.counts toward coverage.answered
noneThe parameters explicitly say there is none — Normalisation: none, Imputation: none, Contaminants: none detected. This is a positive fact and it is the strongest thing a methods section can say about an absence.coverage.stated_none
missingThe parameters never reach the item. Nothing is known, in either direction.coverage.never_mentioned

Collapsing the last two into "missing" is the mistake this design exists to prevent. "No imputation was applied" and "we did not think about imputation" are opposite statements about the same decision, and a review that treats them alike reports a careful methods section as an incomplete one. There is a fourth state the reader can also produce — contradictory, for a value that carries both a none-word and a number, as in Imputation: none, 3 values replaced — and it is never silently resolved to one of the two.

The reading rules matter as much as the states, because this is where a parameter parser normally goes wrong. A none-word only counts when the value is the none-expression, so Contaminants: removed, none remained reads as handled rather than as absent. Labels match on a whole normalised token, so fdr_level cannot be answered by the text of fdr_threshold and a compound label cannot be claimed by one of its parts. A value wrapped onto indented continuation lines is joined before it is read, so a long modification list is not truncated at the first newline. And a sentence-level match reports the line the sentence starts on, which is why a line in a flag is a line you can find.

The error rate, recomputed under both conventions

The decoys are resolved from a decoy column if there is one, from accession prefixes if there is not, and not at all if neither exists — and which of the three happened is reported as fdr.decoy_source, because a rate computed from accession prefixes on an export whose decoys are marked only in a column is a rate over nothing. Rows whose status cannot be read are excluded from the arithmetic rather than assumed to be targets, and their count travels as decoy_status_unresolved so a reply can say the rate is a lower bound.

Then both estimators are computed and both are reported: D/T for separate target and decoy searches, 2D/(T+D) for one concatenated database. They differ by close to a factor of two on the same counts, which is why the app never picks one silently — when the decoy strategy is stated, the matching one is used for the comparison against the threshold and the other is still shipped; when it is not stated, that absence is itself a flag and both numbers are reported. The comparison against the claimed threshold runs only when a threshold and resolvable decoys both exist, so it cannot fire on an assumption.

The digestion, recomputed from the sequences

Missed cleavages are counted from the peptide sequences against the stated enzyme's own rule — trypsin with the proline exception, Lys-C, Arg-C, chymotrypsin, Glu-C, or no enzyme at all — and not read from a column that may or may not exist. When the enzyme is not stated, trypsin is assumed and the assumption is flagged, because almost every run uses it and none of them said so. Peptide length comes from the sequence with modification tokens stripped, so _(ac)AAGVNVEPFWPK is twelve residues rather than eighteen characters.

Modifications, and the check that must not run

The cysteine check is the one place this design is most obviously defensive. Most engines apply a fixed modification silently and never write it into the reported sequence, so a run with carbamidomethylation correctly configured can show zero carbamidomethyl tokens. Reporting that as "every cysteine peptide is missing the fixed modification" is an inverted finding, and an inverted finding at the top of a review corrupts everything a reader takes from it. So the check only runs when the export demonstrably writes fixed modifications at all — at least one row carries the token — and modifications.cys_check_assessable says whether it did. Decoy rows are excluded from the residue denominators for the same class of reason: a reversed sequence's cysteines were never in the sample, and counting them manufactures a search-configuration finding out of the decoy database.

Contaminants, matched on both columns

cRAP classes are matched against the accession and the description together, because the contaminant's name usually lives in the description: an export whose accession column holds a bare P02768 and whose description says "Serum albumin" carries the commonest contaminant in proteomics, and matching the accession alone finds nothing. contamination.matched_on reports which columns were available, so a zero can be read correctly — as "none found in the accessions" rather than as "none present".

The row sample, and why it is not every hundredth row

A forty-thousand-row export cannot be sent whole, and the way it is cut decides whether the review is about the run or about an accident. Rows are drawn by a golden-ratio low-discrepancy sequence over the row indices rather than by a fixed stride, because a PSM export is very often sorted — by protein, by scan, by charge — and a fixed stride resonates with that order: at a stride equal to the block size every drawn row comes from the same block, so the model is handed one protein's peptides and told it is looking at a run. The golden-ratio increment has no period to resonate with.

Two guarantees sit on top of the draw. Every protein group present in the table is represented where the budget allows, so a lane never reviews a run having seen one protein. And the extreme row of every check survives — the worst q-value, the largest absolute mass error, the highest missed-cleavage count, the shortest and the longest peptide, and at least one decoy row if any decoy exists — because a sample that drops the one bad row hands the model a clean list while the browser is flagging a problem, which is the single most misleading thing the sampler could do. The cut is announced in-band, with the real row count and the statement that every statistic in prescan_facts was computed over all rows rather than over the sample.

All of it is free, all of it is deterministic, and none of it needs a token. The lanes exist for the judgements it cannot make: whether an incomplete digest matters for this claim, what to change, and what a sentence in a paper is allowed to say.

Truncation, retries and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix — the findings may be complete while reconciliation, context_notes and body are missing or cut mid-string. In this app that is worse than an error, because body is the last key: a prefix of an ident lane reads as a list of findings with no trust_scope, and trust_scope is the block that says what may not be reported. A prefix of quant is a set of complaints with no min_reportable, and a prefix of methods is a methods paragraph with no deposition checklist and no limitations — a paragraph someone will paste.

Check the flag before you treat a review as complete, and treat a truncation as a retry rather than a repair. Send the same input with a concrete retry_note and the attempt suffix on the Idempotency-Key incremented, so the new body is not a replay of the old key:

"retry_note": "The previous reply was truncated after findings[]. Return the same
findings, keep reconciliation complete for all five prescan uids, and shorten each
evidence_review note to one clause so body.trust_scope is reached."

The same route handles a reply that fails your own checks in the verification list: a missing reconciliation entry, a verdict its findings contradict, a body carrying another lane's keys, a row index past the end of the sample, a claim on both sides of trust_scope. Name the defect in retry_note — it is obeyed exactly — and bump the attempt. Do not append closing braces to truncated JSON; that produces something that parses and is not what the model meant.

The cheaper prevention is to send less: fewer table rows moves the estimate more than anything else in the input, and a 150-row sample that keeps the extremes is a better input than a 600-row one that gets truncated on the way out. If a particular export truncates repeatedly at the same lane, halve the rows before you change anything else.

A last note on grounding, because it changes how you read a clean review. Every finding names the row, the count or the parameter line that produced it, and nothing is invented — not a number, not a threshold, not a row index, not a q-value for a peptide that is not in the sample. So an empty findings array with a full unassessable array is not a pass; it is a statement that the input did not contain enough to judge. Read the two together, and read unassessable before you tell anyone the run is clean. And read context_notes too: a contradicted entry there is the app telling you that something you asserted is not what the export supports.