Appendix II: jobAsync Generator/Comparator Bundle Contracts
This appendix is the authoring reference for a jobAsync challenge variant’s generator and
comparator bundles: the exact files each bundle must produce, how bundle content is kept private
from the student, and the orchestration format used for more comprehensive, explicit-invocation
authoring. For the overall jobAsync mechanism and current availability, see
AI, Jobs, and Integrations.
Enclave: Private, Persistent Job-Group Storage
The enclave is a private, encrypted storage area scoped to one student’s job group — every job created for the same student-and-assignment pair shares one job group, not one per individual attempt. Content written there persists for every later job in that same group and is invisible to every other job group (a different student, or a different assignment/challenge).
Why it exists. The enclave is the actual privacy boundary for a jobAsync challenge’s
generator and comparator bundles. The instructor-authored bundle content is staged into the
enclave once — on the first job created for a job group — and every later job in the same group
reuses it directly, with no re-staging. This is a different, complementary property from the
job-execution-content upload store’s own privacy guarantee (see
“Getting a Bundle Into a Variant” below): the upload store
means bundle bytes are never served back over HTTP to any client at all; the enclave separately
means that even within a running job, the student’s own submitted-code phase can never read the
instructor’s bundle content.
Persistence characteristics. Nothing about the enclave is configured via a variant-create
request field — there is no stickiness or build-cache toggle in evaluationConfigJSON. Fresh
generator input/expected output is still the default on every run (see
“Generator Bundle Contract” below); the enclave is what a generator
uses instead of relying on that default, if it wants persistent or repeatable behavior.
Scope. Persisted content is scoped to the job group only — never shared across different students, assignments, or challenges.
Maturity. The enclave primitive itself is already deployed, but the phase-definition wiring
that would exercise it for jobAsync — for both the generator and comparator bundles — is not
confirmed landed yet. Treat this alongside the same “not yet fully available” status as attempt
execution itself.
flowchart TD
A[Job group's first job] --> B{Bundles already staged?}
B -->|No| C[Stage the variant's generator/\ncomparator bundles into the enclave]
C --> D[Phase execution reads\nfrom the now-populated enclave]
B -->|Yes — every later job\nin the same group| D
How this jobAsync case uses the enclave:
- Bundle staging (privacy). Described above — the mechanism that actually hides instructor content from the student’s own code. Applies to both the generator and comparator bundles equally.
- Build-cache. A compiled generator’s or comparator’s build script (see each contract’s Build
convention below — documented symmetrically for both) writes
generator.bin/comparator.bininto this same persistent area, so it is compiled once per job group and reused, not rebuilt on every attempt. - Stickiness (generator only). A generator that wants genuinely repeatable — not fresh-random — output across every attempt in the same job group can use this same mechanism for its own state. This is documented for the generator specifically; the comparator contract has no equivalent stickiness use.
Bundle File Contracts
A jobAsync variant references two bundles — a generator and a comparator — each addressed by a
digest (see “Getting a Bundle Into a Variant”). The two bundles
communicate through a small set of fixed file names:
flowchart LR
A[Generator bundle] -->|./input| B[Student's run phase]
A -->|./expected| C[Comparator bundle]
B -->|student's produced output| C
C -->|./result.json| D[ChallengeResult]
Generator Bundle Contract
- Input: none beyond the bundle’s own content. No run parameters, seed, or student identity are passed in — anything the generator needs must be bundled with it.
- Output: exactly two files,
./inputand./expected. Both are opaque byte streams; the content format is the author’s choice. - Freshness: fresh-random on every run by default.
- Stickiness: opt-in stable/repeatable output — see “Enclave: Private, Persistent Job-Group Storage” above.
- Test-case count: fixed at exactly one
(input, expected)pair per attempt. A permitted (but unstandardized) workaround is to pack several logical test cases into the single pair and let the comparator split them back apart. - Build convention: a compiled generator is built by a
make.shscript producinggenerator.bin, persisted via the enclave’s build-cache use (above); the script itself should skip rebuilding when its output already exists.
Comparator Bundle Contract
- Input: three fixed files —
./input,./expected(both from the generator), and the student’s produced output. - Output: exactly one file,
./result.json, shaped like the AI-grading result contract used elsewhere on this platform:version(must be1),pass(boolean),awardedScore/maxScore(numbers),feedback.summary(non-empty string), an optionalfeedback.linesarray of{line, expected, actual}objects (issue #4648), and an optionalrubricBreakdown.itemsarray — see ChallengeVariantEvaluationConfigJSON’saiRubricrubric-criteria fields for the equivalent shape.pass: falsewith a fractional, nonzeroawardedScoreis valid (issue #4648 relaxed the priorpass:false => awardedScore: 0restriction so partial credit can be reported alongsidepass: false);pass: truemust reconcile toawardedScore == maxScore. - Exit-code convention: exit
0is required for./result.jsonto be trusted at all. Any nonzero exit is treated as if no result file existed, even if one is present and parses successfully. - Build convention: mirrors the generator’s —
make.sh→comparator.bin, using the same enclave build-cache mechanism. - Execution: language-agnostic and sandboxed — a comparator can be a shell script, an interpreted script, or a compiled binary.
Orchestration Format (generatorRunCommand/comparatorRunCommand)
ChallengeVariantEvaluationConfigJSON
carries two configType shapes for a jobAsync-strategy variant: the minimal jobAsync configType
(testCaseCount only — bundles are referenced solely by digest, with no author-specified
invocation), and the fuller jobSpec configType, which additionally requires
generatorRunCommand/comparatorRunCommand. This section documents that second, fuller shape —
needed for more comprehensive, explicit-invocation authoring.
Each of generatorRunCommand/comparatorRunCommand is an object with:
target(string, required) — the executable to invoke, e.g. a script or compiled binary path within the bundle. This is exactly the kind of path the Build convention sections above describe: amake.shinvocation, or a directly-invoked pre-builtgenerator.bin/comparator.bin(for example,"enclave/generator/make.sh"or"enclave/comparator/make.sh").arguments(array of strings, optional) — passed to the executable.
Only the invocation shape belongs on this object — a retry count, timeout, poll cadence, queue name, or any other runtime/orchestration-platform concern is never a valid field here.
Known gap. What the platform invokes when the simpler jobAsync configType is used (no
explicit run command at all) is not yet part of the published contract. This gap is not closed
by the runtime-selected pipeline below (#4648): runtime is valid only for the jobSpec
configType, not jobAsync — see the next section for exactly what it closes instead.
Runtime-Selected Pipeline (runtime/comparator/submissionFileName)
ChallengeVariantEvaluationConfigJSON
also carries an optional runtime field (issue #4648), valid only for the jobSpec configType.
It closes a different, narrower gap than the one named above: for a jobSpec variant, an
author no longer needs to write generatorRunCommand/comparatorRunCommand invocation steps
or author a custom comparator bundle — runtime selects a fully system-provided pipeline for
both halves at once. What still remains open is the jobAsync configType’s own question (what the
platform invokes with no explicit run command and no runtime field at all); that is unaffected
by this section.
runtime is an object with:
language(string, required) — the submission’s programming language, e.g."cpp".toolchain(string, required) — the toolchain used to compile/run it, e.g."gcc".
Together, language/toolchain must match one of a fixed, server-side allow-list of runtime
profiles. Two are recognized today: cpp/gcc (submission extension cpp) and python/python3
(submission extension py, gated on run/python/stdin@1 actually shipping). An unrecognized pair
is rejected at variant-authoring time.
When runtime is present:
comparatoris required (not merely presence-waived) — the fixedcompare/standard@1phase always consumes acomparator.jsonfile staged from this object, so a runtime pipeline without a comparator configuration cannot run.generatorRunCommand/comparatorRunCommandare forbidden — the generate step is the fixedgenerate/enclave-runner@1phase and the compare step is the fixedcompare/standard@1phase, so an author-supplied command has nothing to bind to.phaseDefinitionSetis forbidden (mutually exclusive withruntime).submissionFileName(string, optional) may be supplied: a single path segment (no/,\, or leading dot), 1-255 characters, whose extension must be in the resolved runtime’s allowed set (e.g.cppfor thecpp/gccruntime). It is optional, not required, because the Studio always sends it but a CLI-authored variant may not.
Assembled pipeline. The platform assembles three phases in order:
generate/enclave-runner@1 (unzips and stages the instructor’s enclave-provided content) ->
the runtime’s own run phase (e.g. run/cpp/lint-compile-run-stdin@1, which lints, compiles, and
runs the student’s submission against stdin) -> compare/standard@1 (the built-in comparator,
configured by comparator.json). This differs from the generatorRunCommand/comparatorRunCommand
shape above in one structural way: it also transfers a THIRD security policy
(.merlin.securityPolicy.standard-compile-and-run, selected by the compile/run phase) alongside
the schema and standard-untrusted policy every jobAsync document already transfers.
Staging. The student’s submission is staged as submission.<ext> (extension from the resolved
runtime, e.g. submission.cpp), and a sibling comparator.json is staged alongside it – a
canonical JSON document with all four comparator keys always present (mode,
ignoreLineWhitespace, significantFigures, caseInsensitive), significantFigures an explicit
JSON null when the mode is not numeric.
An absent runtime leaves the generatorRunCommand/comparatorRunCommand shape above entirely
unchanged; a variant may use either shape (or neither, plus phaseDefinitionSet), never both.
System Phase-Definition Sets (phaseDefinitionSet)
ChallengeVariantEvaluationConfigJSON
also carries an optional phaseDefinitionSet field (issue #4507), valid for either the jobAsync
or jobSpec configType. This is a separate mechanism from generatorRunCommand/
comparatorRunCommand above: phaseDefinitionSet selects among a small, fixed set of
system-authored generate/run/compare phase-definitions, not per-instructor bundle execution — the
per-instructor bundle/enclave-runner pipeline this appendix otherwise documents is a distinct,
larger capability that this field does not touch. It is also mutually exclusive with runtime
(issue #4648, previous section) — a jobSpec variant selects at most one of the two.
Three named values are recognized:
python-echo-pair(default when the field is absent) — the original worked example: thegeneratephase always writes a random(input, expected)pair and exits0, and thecomparephase wraps its own read of therunphase’s output in atry/except, so a missing or malformed result degrades to a low-score completion (pass: false) rather than a phase failure.python-generator-fault-injected— swaps only thegeneratephase for a fixed, deterministically nonzero-exiting script (urn:ayode:resource:/codermerlin.academy/system/phase-definitions/generate/echo-pair-generator-failure@1), leavingrun/compareunchanged. Surfaces thegeneratorFailureterminal-failure category through a real HTCondor round trip.python-comparator-fault-injected— swaps only thecomparephase for a fixed, deterministically nonzero-exiting script (urn:ayode:resource:/codermerlin.academy/system/phase-definitions/compare/exact-match-comparator-failure@1), leavinggenerate/rununchanged. Surfaces thecomparatorFailureterminal-failure category through a real HTCondor round trip.
Each fault-injected set swaps exactly one phase, not both: a single “both generate and compare
fault-inject” set would only ever surface generatorFailure, since the pipeline aborts at
generate before compare ever runs — making comparatorFailure permanently unreachable through
it.
An absent or unrecognized phaseDefinitionSet value falls back to python-echo-pair at execution
time (the backend also rejects an unrecognized value at variant-authoring time — see
validateJobAsyncPhaseDefinitionSet in internal/domains/lessons/delivery.go).
Getting a Bundle Into a Variant
- Upload the bundle bytes to the job-execution-content store — uploadJobExecutionContentBundleV2 for bundles up to 4 MiB, or initializeExecutionContentUploadV2 for larger bundles — to receive a digest.
- Pass the returned digest into
createChallengeVariantForChallengeV2
as
generatorBundleDigest/comparatorBundleDigest.
generatorBundleDigest is always required this way. comparatorBundleDigest may be skipped
entirely when the variant instead selects the standard (built-in) comparator via
evaluationConfigJSON.comparator (#4649) — there is no bundle to upload or reference in that
case, since the comparison logic is one of the platform’s built-in modes rather than authored
source.
Bundle content has no read-back HTTP route anywhere — only the addressing digest is ever visible on a variant.
See AI, Jobs, and Integrations for the overall jobAsync mechanism and current availability.