OTTER-524: surface why a job errored, and let the reviewer close out a run with no outputs - #973
Conversation
…icate errored row
…age, and files this screen cannot offer
…hat the run produced
nathanstitt
left a comment
There was a problem hiding this comment.
looks good, thanks Marvin
| export const JOB_FAILURE_REASONS = ['BASE_IMAGE_UNAVAILABLE'] as const | ||
| export type JobFailureReason = (typeof JOB_FAILURE_REASONS)[number] | ||
|
|
||
| export function isKnownFailureReason(value: string | null | undefined): value is JobFailureReason { |
There was a problem hiding this comment.
Worth calling out explicitly in the code rather than only in the PR description: /api/job/[jobId] accepts an arbitrary message from any org-authenticated producer and writes it straight into this same column. So any enclave that knows the string can PUT { status: 'JOB-ERRORED', message: 'BASE_IMAGE_UNAVAILABLE' } and have latestRecordedJobFailureReason pick it up, and the reviewer reads a containerizer classification that the containerizer never sent.
The blast radius is small today (one code, and the sentence it produces is advisory), so I don't think this blocks. But the "not in this change" note about producer provenance is the kind of thing that gets lost once the PR is merged — could we drop a short TODO(OTTER-xxx) here or on latestRecordedJobFailureReason so the next person adding a code to JOB_FAILURE_REASONS sees that the column is not trusted-by-source?
There was a problem hiding this comment.
Agreed, and the note is now in the code: job-error-details.ts L93.
It sits on the JOB_FAILURE_REASONS doc block, so the next person to add a code reads it before they add one.
I checked the route before I wrote the note, so the wording is narrower than the PR body was. PUT /api/job/[jobId] validates message as a free-form optional string. The handler then requires that the requesting org owns the study. So the writer is the study's own org, not any org-authenticated caller. The result is the one you describe: that org can send a code from this list, and the reviewer reads it as a containerizer classification.
The note states the rule for the next code, rather than only the current risk: the list is trusted by value, not by source. Before we add a code whose sentence a reviewer would act on, the classification needs its own column or a source marker.
The marker says TODO(OTTER-524). I have no write access to the board from here, so I did not invent a key. Tell me the number if you want a dedicated follow-up card, and I will re-point it.
| <UnlockedPhase | ||
| decryptedFiles={decryptedFiles} | ||
| decryptedFiles={reviewableFiles} | ||
| canShareOutputs={requiresKey} |
There was a problem hiding this comment.
canShareOutputs={requiresKey} reads oddly — the two names answer different questions, and it took me a couple of passes to convince myself it's correct. It is: UnlockedPhase bails when decryptedFiles === null, so by the time canShareOutputs is consumed, requiresKey === true really does imply "decrypted, and there are files". But that's an invariant enforced two components away from where the prop is read.
Could we make the equivalence explicit rather than incidental? Something like a named local at line 80-88 —
// Same value as requiresKey: with no key step there is nothing decrypted, so nothing to share.
const canShareOutputs = requiresKey— costs nothing and means a future edit to UnlockedPhase's null guard doesn't quietly turn a "has a key step" boolean into a sharing permission.
There was a problem hiding this comment.
Done, close to your wording: outputs-review-panel.tsx L89-L93.
The named local carries the equivalence and the reason for it, so the prop site now reads canShareOutputs={canShareOutputs}. Your point about the distance is the part I wanted to fix. The invariant lived two components away, and nothing at the prop site said which question the value answered.
| .select('message') | ||
| .where('studyJobId', '=', studyJobId) | ||
| .where('status', '=', 'JOB-ERRORED') | ||
| .where('message', 'in', [...JOB_FAILURE_REASONS]) |
There was a problem hiding this comment.
Small robustness thing: JOB_FAILURE_REASONS is a single-element tuple today, and if it ever goes to zero (someone retires BASE_IMAGE_UNAVAILABLE before adding a replacement) Kysely renders where message in (), which Postgres rejects as a syntax error — so the reviewer's errored screen 500s rather than degrading to the stage sentence.
Cheap to make impossible: if (!JOB_FAILURE_REASONS.length) return null at the top, or keep the filter in TS by selecting the newest classified row's message and running it through isKnownFailureReason. Either way the failure mode becomes "no reason shown" instead of "page down".
There was a problem hiding this comment.
Good catch. Fixed with the guard: queries.ts L158-L169.
The query returns null before it builds the statement, so the failure mode becomes "no reason shown" and the screen falls back to the stage sentence.
I kept the filter in SQL rather than moving it to TypeScript. The SQL filter is what makes the answer independent of which JOB-ERRORED row sorts last, and it is also why raw service text never leaves the database. Selecting the newest row and classifying it in TypeScript would give up both properties.
The array is typed as string[] on purpose. JOB_FAILURE_REASONS is a const tuple, so a length check against the tuple type reads as always false. Widening it keeps the guard honest to a reader and to the compiler.
| // asks what it may promise about THIS run's outcome, while all this one asks is whether a key | ||
| // opens anything at all, so a decided job whose only encrypted artifact is a scan log keeps its | ||
| // re-decrypt instead of losing it to a predicate written for a different question. | ||
| const hasDecryptableOutputs = jobHasEncryptedArtifacts(job.files ?? []) |
There was a problem hiding this comment.
The wider predicate here means a decided job whose only encrypted artifact is ENCRYPTED-SECURITY-SCAN-LOG keeps its "View outputs again" form — and the test on line 236 of the spec pins that as intended. But that's the exact shape of the reported bug, just on the next screen: the reviewer enters a key, and what comes back is a submission-time scan log rendered under "outputs", which is the conflation the errored screen now refuses to make.
The comment argues this screen asks a narrower question ("does a key open anything"), which is fair, but the answer it gives the user is still framed as this run's outputs. Is a scan log actually something we want offered here, given the same log is already on the code review step? If it is, no change needed — but it'd be worth saying why it's useful post-decision rather than only why the predicate differs.
There was a problem hiding this comment.
You are right, and I changed the behavior rather than defend it.
The screen now asks the same question as the errored screen: reviewer-outputs-decided.tsx L47-L53. The test now expects no key form for a scan-log-only job: spec L241-L250.
I could not answer your question with a use for it. The form says "The outputs are encrypted. Enter your security key to view them again." A reviewer who enters a key then receives a submission-time scan log under that promise. That is the same conflation, one screen later, and the log is already on the code review step. So there was nothing to explain, only something to remove.
What the narrow predicate costs is small. jobHasDecryptableRunOutcome still covers every ENCRYPTED-RESULT, the encrypted code run log, and the encrypted packaging error log. Only a scan-log-only job loses the form, and that job has nothing else for a key to open.
Two follow-on edits keep the story in one piece:
- the
isVisibledoc onDecryptAndViewOutputsnow states the narrower question, since the prop name alone does not carry it docs/study-screens-logic.mdsaid the gate was deliberately wider. It now says both screens use one predicate.
jobHasEncryptedArtifacts stays where it is. isLegacyResultJob still needs the wide question.
| // This set therefore OVERLAPS the encrypted one on an ordinary packaging failure, which holds both | ||
| // halves of the same log. Only errorLogSentence's ordering resolves that, by asking the decryptable | ||
| // question first; a caller that asks this one alone will call a readable log undisplayable. | ||
| const UNDECRYPTABLE_ERROR_LOG_TYPES: FileType[] = [ |
There was a problem hiding this comment.
APPROVED-SECURITY-SCAN-LOG is (correctly) left out of this list, matching the exclusion in ENCRYPTED_ERROR_LOG_TYPES — but nothing in the file says so, and the comment above only explains the PACKAGING-ERROR-LOG and legacy-APPROVED-* inclusions. Since the whole point of the module is that "any log" and "a log about a failed run" are different questions, the absence of the scan log from both sets is the load-bearing bit and the easiest thing for a future edit to undo. One line noting the deliberate omission would lock it in.
(The combinatorial test in job-error-details.test.ts covers SECURITY-SCAN-LOG but not APPROVED-SECURITY-SCAN-LOG, so adding it to CANDIDATES would pin it there too.)
There was a problem hiding this comment.
Both parts done, and you named the right risk. The absence carries the meaning, and an absence is what a later edit removes without noticing.
- the comment states the omission and why:
file-type-helpers.tsL75-L82. It says the scan log is not a log about a failed run in either form, and it tells the reader not to complete theAPPROVED-*trio. APPROVED-SECURITY-SCAN-LOGnow joinsCANDIDATES:job-error-details.test.tsL185-L187.
The sweep now runs over eight file types, so both rules hold across 256 combinations each. Adding the type to either error-log set fails the first rule at once: the banner would offer a key that the gate does not render.
Total coverage
Detailed report9 files with a coverage regression
|
see OTTER-524
Companion PR: safeinsights/iac#221. Either side can merge first. See "Deploy order" below.
What was reported
A study ran against a code environment with a wrong image URL. The run errored. The reviewer's page showed no error log. Only the security scan log appeared. At the same time, the copy told the reviewer to review error logs.
What the job actually did
I checked the reported job in staging CodeBuild before I made any change. Job
019db1e5-bd39-729b-8246-63b2668215d2:UNAUTHORIZED: project opensta not found. The URL saidopensta, notopenstax.{"jobId":"019db1e5-...","status":"JOB-ERRORED"}The missing error log is not a display bug. The build sent nothing. No log exists anywhere else. AWS writes no container log when a task does not start. The packaging step sends no log.
Three causes
FILES-*decision was possible. The researcher then stayed on "code is running" forever, because that screen ends only when a decision exists.What this change does
Name the stage that failed
src/lib/job-error-details.tsreads the failed stage from the status history. This works when the build sends nothing:JOB-PACKAGINGbut noJOB-READY: packaging failed. The image could not be prepared, so the code never ran.JOB-READYbut noJOB-RUNNING: the job was packaged, but it never started.JOB-RUNNING: the code ran and did not finish./api/services/job-scan-resultsand/api/job/[jobId]can both recordJOB-ERROREDbefore the containerizer starts. That reviewer must not read that the image could not be prepared.Report the reason for the failure
The companion iac PR wraps one build step: the
crane configcall. That step fails when the image URL is wrong, when we cannot read the image, or when someone deleted the image after approval. The step then adds one key to the failure payload:{ "jobId": "...", "status": "JOB-ERRORED", "failureReason": "BASE_IMAGE_UNAVAILABLE" }This app maps that code to a sentence. That sentence replaces the stage sentence.
Three rules keep infrastructure detail out of this channel. Each rule has a test.
jobErrorDetailscompares the stored value against a known list. It then shows our own sentence. An unknown value falls back to the stage sentence. This rule also protects us from the enclave, which writes a raw AWS error into the same column today.latestRecordedJobFailureReasonfilters on the known code set in SQL. An empty set would rendermessage in (), which Postgres rejects, so the query returns null before it builds that statement. If someone retires the last code, the reviewer reads the stage sentence. The screen does not fail.latestRecordedJobFailureReasonis a separate small query. The webhook route stores only known codes, and the query selects only known codes. Raw service text therefore never leaves the database.latestJobForStudyQueryandgetStudyJobInfodo not selectmessage. Both of those feed actions that the submitting researcher can call.The build classifies only that one step. An S3 failure or a Harbor failure is our problem, not the data partner's. Those failures keep the bare payload, and the reviewer keeps the general sentence.
Use one rule for the banner and the key form
The errored screen asks one question. Does this job hold an encrypted artifact about the run's own outcome? That artifact is an
ENCRYPTED-RESULTor an encrypted error log.jobHasDecryptableRunOutcomeanswers the question. The same module builds the banner sentence from the same file list. The screen therefore cannot say one thing and do another.ENCRYPTED-RESULT, no error logENCRYPTED-RESULTplus a plaintext or legacy error logThe screen asks the decryptable question first. An ordinary packaging failure stores both halves of one log: the encrypted log for the key holder, and a plaintext twin. The screen reports that log as readable, not as a log that it cannot display.
The security scan log does not count towards the question. The code scanner writes it at submission. It already appears on the code review step. It says nothing about a run. Two problems came from counting it. An errored job that held only that log was told to review error logs that it did not have. The screen also offered "share outputs" for a run whose only artifact was a submission-time scan log.
Two unit tests check the rule across every combination of artifact types. The banner mentions a key exactly when the key form renders, and it never denies a log that the job carries.
Let the reviewer close the round
An errored job can hold nothing to decrypt.
OutputsReviewPanelthen skips the key step and shows the decision directly. The panel disablesShare outputs and feedbackand gives the reason.Share feedback onlyis the one option that remains. The server already permits this, because the "shares every artifact" check runs only on the sharing branch. The form still requires a selection, and it has no default. Closure of the round stays a deliberate act.Two safeguards need a close review:
Every
ENCRYPTED-RESULTstill needs a key. No reviewer can decide on outputs that they did not see.Keep the decided screen honest
ReviewerOutputsDecidedshows the post-decision "View outputs again" key form only when the job holds an encrypted artifact. A reviewer can now close out a run that produced nothing. Without this gate, a return to that page asks for a key against files that do not exist, and no key can open them.The gate is the same rule as the errored screen, on the same predicate. The form promises the reviewer their outputs. A submission-time security scan log is not an output of the run. A decided job whose only encrypted artifact is that log therefore has no re-decrypt form. The log stays available on the code review step, which is where a submission-time scan belongs.
Deploy order
Either repository can merge first. Nothing breaks.
Two details hold this up. Please do not tidy them away:
failureReasonis an optionalz.string(), not az.enum. An enum rejects a code that this app does not know yet. The route then returns a 400, and the job is never marked as errored.The buildspec also has a fallback path that always posts a bare payload. This app must therefore accept a webhook with no reason forever.
Delivery order does not matter, in either direction. A classified failure arrives twice. The build script posts from its own catch handler. The buildspec fallback then posts a bare payload, because
post_buildruns after the build phase exits non-zero. The reason survives in each order:JOB-ERROREDrow from any producer: the reviewer-scoped query selects the newest row that holds a known code, not the newest row. An enclave row or a/api/job/[jobId]row therefore cannot mask a reason when it sorts last.The route keeps a classified code that a row already holds. It replaces unclassified text on that row, because the app never displays such text, and the text otherwise hides the one value that the screen can explain. Each of these cases has a test.
About the acceptance criteria
The card asks us to show the error log for a study that ran against a failed image or a deleted image. Both cases fail during packaging. As shown above, no error log exists on that path. With the companion iac PR, the reviewer instead reads a checked explanation of that exact failure. That explanation is the information that the AC asks for.
Product must confirm two points:
The merge with main
This branch now includes OTTER-696 (PR #972). That card moved four pieces of code into shared modules. This branch uses each shared module instead of its own copy:
useDecryptPhaseOutputsReviewPanelandDecryptAndViewOutputsstatusAlertTitleFeedbackAndNotesSectionwith aloadErrorpropReviewerOutputsDecidedPreviousStepLinkReviewerOutputsDecidedOutputsReviewPanelkeeps the gate that this card adds. The hook gives the decryption flip. The panel then appliesrequiresKeyabove the hook, because a run with no artifact has no key step.The two cards do not overlap. They divide an errored run by what the reviewer can share:
outputs-errored-sharedscreen from OTTER-696.Share outputs and feedback. Feedback only is the one possible decision, and the researcher goes tostudy-results.This branch also removes one duplicate test fixture. Both cards wrote the same artifact fixture at the same time.
shared-outputs-panel.test.tsxnow callsseedEncryptedArtifactfromtests/artifact.helpers.ts. That fixture makes the same zip and returns the same shape, so the suite asserts the same behavior.Testing
pnpm run checkspasses on this branch after the merge with main.pnpm run testpasses in the iac repo, 13 tests.The full unit suite passes locally: 292 files and 3419 tests.
One test needs a note for anyone who runs the suite on a reused local database.
acknowledgements-table.test.tsxlists every user in the database, 25 to a page, sorted by name. Two of its cases look for their own user on the first page. The name comes from a generator, so the case passes only while the database holds few users. A local unit-test database that many runs have written to holds hundreds, and the case then fails. It passes on a fresh database. The test does not touch this branch, and it behaves the same way on main.New coverage in this repo:
JOB-ERROREDrowWe agreed not to run E2E.
Not in this change
BASE_IMAGE_UNAVAILABLE. Every other failure keeps the general stage sentence, which is the safe default.StudyJobhas no code environment reference. A foreign key cannot survive the case that this card names, because deletion of a code environment also deletes its scan history. A snapshot on the job is the right direction. It needs a migration, so it needs its own card.PACKAGING-ERROR-LOG, or of a legacy plaintext result, on this screen. Both are better than a sentence that says we cannot offer them. Each needs a new fetch path and new UI, and the reviewer flow has never supported them.isFeedbackOnlyOutcomeexcludes an errored run. A run that this card closes with feedback only therefore goes tostudy-results. The researcher reads theFILES-REJECTEDmessage about withheld results and PII, and the screen does not show the reviewer's feedback.PUT /api/job/[jobId]lets the org that owns the study write anymessage. A future producer that shares the enum can therefore have its value read as a containerizer classification. The sentences are advisory today, so nothing acts on the value.JOB_FAILURE_REASONSnow carries that limit as a note, so the next person to add a code reads it first. A dedicated column or a source marker fixes the cause. That needs a migration, so it belongs on its own card.Notes for review
Overview
The reviewer's errored outputs screen now tells the truth about a failed run, explains the failure, and lets the reviewer finish: