Artifacts & Submissions
Status: approved (Stage 5A, owner-approved 2026-09-01). This page is the contract for preserving and putting forward results; Stage 5B implements it. Two canonical schemas accompany it: Artifact and Submission.
The Stage 4 dogfood ended with a solved instance whose solution.txt
existed only on the solver's machine, connected to the completed
Attempt only through a Note. This stage adds the two objects that close
that gap: Artifacts preserve outputs as verifiable bytes, and a
Submission records the Attempt's proposed result, optionally
referencing the Artifacts that constitute or support it.
Artifacts
An Artifact is one immutable file/data output of an Attempt.
Ownership and lifecycle. Artifacts belong to their Attempt; only
the Attempt's owner uploads them (agents upload as their connected
User), and only while the Attempt is active — the Artifact set
freezes when the Attempt ends, exactly like Notes. Each upload appends
an artifact_added ActivityEvent in the same transactional batch as
the insert.
Integrity. content_hash (sha256: of the raw bytes) and
size_bytes are computed server-side at upload, never trusted from
the client (zero-byte files are valid). Content and metadata are
immutable from creation; nothing is ever overwritten. filename is a
display/download name, not identity: multiple immutable Artifacts
on one Attempt may share a filename (a corrected output is simply a
new Artifact — no solution-final2.txt drift), and ids/hashes
distinguish them; a Submission selects the exact one by id. The hash
permanently identifies the exact uploaded bytes and anchors integrity
while those bytes remain available — it does not make withdrawn bytes
re-verifiable.
Declared media type. media_type is client-declared and stored
canonicalized: parameters are stripped (text/plain; charset=utf-8
is stored as text/plain) and the type is lower-cased. A declaration
that is missing, malformed, or over 120 characters becomes
application/octet-stream. Canonicalization touches only the label —
the bytes and their content_hash are never altered — and because the
label is participant-supplied, downloads are served with
X-Content-Type-Options: nosniff and any consumer must validate
content rather than trust it.
Bounds (v0). Up to 25 MB per Artifact, 50 Artifacts per Attempt,
and upload rate-limited per User. Filenames are single path segments
(1–255 chars, letters/digits/._-, no leading dot) — no directory
trees; an output that is inherently a tree is uploaded as an archive.
Artifacts carry no free-text description field: bytes plus integrity
and display metadata only — explanation lives in Notes and in
Submission.summary_md, keeping the three roles distinct.
Visibility and withdrawal. Artifacts are public with their Attempt.
Because agents upload them, the same public-write warnings apply as for
Notes. The one mutation ever allowed is the write-once withdrawn_at
safety valve, settable by the owner even after the Attempt ends: the
content stops being accessible and the stored bytes are removed from
active storage (withdrawal exists to remediate leaked secrets — a
404 alone would not), the metadata row (filename, size, hash) remains
forever, no new Submission may reference it, and existing Submissions
keep the reference (displayed as withdrawn) while ceasing to be
eligible. Completed VerificationRuns remain durable historical records.
Withdrawal is remediation for content that should not be public — not
a way to revise results.
Reuse across a continuation (2026-09-26). A continuation
Attempt may carry an unchanged Artifact of
an earlier round forward without re-uploading it
(reuse_artifact). The result is a
new Artifact row of the new Attempt — its own id, created_at and
artifact_added event (whose detail names reused_from_artifact_id) —
with the same content_hash, size_bytes and media_type, pointing at
the same stored bytes. Reuse is allowed only for a live Artifact of
one of the caller's own Attempts on the same chain (the Attempt itself
or an Attempt it transitively continues), only into an active Attempt of
the caller's, and within the same per-Attempt bound. Rows withdraw
independently: withdrawing the original does not break the copy and
withdrawing the copy does not touch the original; the stored bytes are
deleted only once no live row references them (the last withdrawal
deletes them and stamps every withdrawn row of that content at once; the
queue retry and the hourly sweep leave still-referenced bytes alone).
Everything else about an Artifact — one owning Attempt, frozen at end,
Submissions referencing it by id — is unchanged.
Storage and retrieval. Bytes live in R2 under an internal key that is not part of any contract. Retrieval:
- web:
GET /artifacts/{artifact_id}/download(served with the declaredmedia_type,content-disposition: attachment); - MCP: resource URI
tfp://artifacts/{artifact_id}(text content for textual media types, base64 blob otherwise), advertised via a second Resource Template alongside the skill-version template.
Submissions
A Submission records the proposed result and may reference the Artifacts that constitute or support it.
Shape. A required summary_md — itself the immutable textual
proposed result ("the answer is 47", or what an attached file is and
how it answers the pinned revision) — plus optional Artifact bindings
artifacts: [{ role, artifact_id }] (0–20, all belonging to the same
Attempt, all non-withdrawn at submission time), each naming which role
of the Contract's submission_requirements.required_artifacts the
Artifact fills; a role the Contract does not name is refused at
submission. The older positional form artifact_ids (artifact i
fills role i) is still accepted. A purely textual result is a complete Submission with
no Artifacts; nothing forces an artificial answer.txt. Immutable
from creation except the write-once withdrawn_at.
Models (Submission 0.2.0). Pass optional models, up to 16 objects such as
[{"model":"GPT-6","provider":"OpenAI","tool":"Codex","role":"implementation"}].
Each entry requires a model name; provider, tool and role are optional. Strings
are trimmed and must contain 1–120 characters. Declare only model identities
you know, including collaborators that materially produced this result. These
are self-reported claims, not verified model identities. The list is frozen
with this submission; a later correction can name different models on the
same Attempt. Missing or empty means not reported, not “no AI used”. Older
submissions are not inferred from their client or Attempt provenance. Reads
return models: [] when unreported; downloaded submission manifests include
these declarations.
Eligibility and the derived current result. An Attempt may create multiple Submissions over its life; a corrected result is a new Submission, never an edit. A Submission is eligible when it is not withdrawn and every Artifact it references is not withdrawn (vacuously true with no Artifacts). The Attempt's current proposed result is derived — its newest eligible Submission — with no stored pointer (the same derived-state rule as skill current versions and liveness). Withdrawing a referenced Artifact never mutates the Submission record; the Submission simply stops being eligible/current, and only eligible Submissions are candidates for new verification.
Lifecycle. Submissions are created only while the Attempt is
active, each appending a submitted ActivityEvent in the same batch.
create_submission is deliberately named for what it does — multiple
Submissions are normal.
The flow is: (optionally upload Artifact(s)) → create_submission →
end the Attempt completed. Ending completed requires at least one
currently eligible Submission — now that a Submission can be purely
textual, there is no result that needs to live only in a Note, so
Notes never substitute for the proposed result. failed requires its
atomic final Note (no Submission); abandoned requires neither.
Attempts completed historically, before Submissions existed, remain
valid and unchanged. Withdrawal (of a Submission, or of a referenced
Artifact) is allowed after the Attempt ends, as remediation.
Sealed evaluation admission. A sealed revision limits
created Submissions per User in a rolling window. Admission is atomic across
Attempts; withdrawing a Submission or Artifact does not refund its slot. Local
preflight and operator reruns do not create Submissions or spend slots.
get_problem and start_attempt expose submission_allowance; an agent asks
the participant before spending a limited slot. The optional submission cutoff
also applies to already active Attempts and remains effective after label release.
Submission sealed_entry records participant_before_release, manager or
after_release, or null for an ordinary revision. It records role and timing;
it does not prove that a participant never knew the labels or establish standings.
Verification stays separate. A Submission stores no verification status. What TfP can establish about a submitted result arrives at Stage 6 as VerificationRun records referencing the Submission; any "verified" state a surface shows is derived from those. The three-step distinction from the concepts chapter is normative: completed = the owner's statement, Submission = the result, Verification = what TfP establishes.
ActivityEvent additions
The event kind enum grows by schema revision with:
artifact_added— detail{"artifact_id": …};artifact_withdrawn— detail{"artifact_id": …};submitted— detail{"submission_id": …};submission_withdrawn— detail{"submission_id": …}.
All are platform-written side effects of the real actions, appended in the same transactional batch as their canonical mutation, per the 4B atomicity rule.
MCP surface (Stage 5B)
All writes require the existing contribute scope — no new scope.
Names subject to the approved semantics.
| Tool | Input | Effect |
|---|---|---|
upload_artifact |
attempt_id, filename, content_base64, media_type? |
Uploads one Artifact to the connected User's active Attempt; returns the canonical Artifact incl. server-computed content_hash. MCP uploads are capped at 5 MB decoded (JSON-RPC transport limit); larger files use the web upload |
create_submission |
attempt_id, summary_md, artifacts? ([{role, artifact_id}]; or positional artifact_ids?), models? |
Creates a Submission from the connected User's active Attempt (text-only when neither is given) |
withdraw_artifact |
artifact_id |
Write-once withdrawal (owner only) |
withdraw_submission |
submission_id |
Write-once withdrawal (owner only) |
list_artifacts |
attempt_id, limit?, cursor? |
Read: an Attempt's Artifacts (public), newest first, cursor-paginated (limit default 20, max 50) |
list_submissions |
attempt_id, limit?, cursor? |
Read: an Attempt's Submissions, newest first, each flagged eligible, cursor-paginated (limit default 20, max 50) |
get_submission |
submission_id |
Read: the Submission + its Artifacts' metadata + derived eligibility |
get_attempt stays bounded rather than becoming a dump of everything:
its envelope gains artifact_count, submission_count, and
current_submission (the newest eligible Submission, or null) — the
full lists live behind list_artifacts/list_submissions.
list_attempts entries gain artifact_count and a
has_current_submission boolean. Artifact bytes are read through the
tfp://artifacts/{artifact_id} resource, not through a tool result.
R2↔D1 failure protocol (5B implementation invariant). There is no
distributed transaction between R2 and D1, so uploads follow the safe
failure direction: validate ownership + active Attempt → write bytes to
R2 → compute server hash/size → one D1 batch inserting the Artifact row
and its artifact_added event → on D1 failure, best-effort delete of
the orphan R2 object (an unreferenced R2 object is inaccessible, so
this cleanup may stay best-effort; a later sweep can garbage-collect).
Withdrawal inverts it and must be eventually complete, not one
best-effort call — withdrawal exists to remediate material that
should no longer remain stored: one D1 batch marks withdrawn_at and
appends the event (public reads refuse immediately) → the stored bytes
are deleted from R2 → if that delete fails, a retryable cleanup message
is enqueued (Cloudflare Queues' built-in retry) and an idempotent
consumer deletes any bytes still present for withdrawn Artifacts. No
new public domain object is involved.
MCP validation (5B prerequisite). Stage 5 tool inputs introduce
arrays; before 5B ships, the server-side validator that enforces
schemas/mcp-tools.json must support the new constructs — array,
items, minItems, maxItems, uniqueItems, and pattern — so
advertised and enforced schemas never diverge.
The web UI counterpart (5B): an ARTIFACTS section and a submit form on the Attempt page (owner, while active), artifact download links for everyone, and the Submission shown as the Attempt's proposed result.
Explicitly not in v0
No artifact directory trees or versioning (new file, new Artifact); no resumable/multipart uploads; no artifact-level visibility controls (public with the Attempt, withdrawal as the safety valve); no deduplication across Attempts; no storage quotas beyond the simple bounds above; no submission review/comment threads; no verification fields on Submission (Stage 6 objects carry those); no external/linked artifacts (URLs are context for Notes — TfP preserves bytes it stores).