Context
Neo.form.field.FileUpload speaks exactly one transport: XHR multipart (src/form/field/FileUpload.mjs:439-490), with fetch hardcoded for document delete (src/form/field/FileUpload.mjs:633) and scan-status polling (src/form/field/FileUpload.mjs:658). Progress, abort, and completion are welded to XHR events.
That is fine for apps with an HTTP backend. It excludes a growing class of Neo apps whose only backend channel is a message transport — websocket or worker message port — where the "backend" is an agent-side service and there is no HTTP plane at all. The Agent Harness direction (ADR 0020, #13012) increases the share of apps in exactly that situation: a harness-hosted app talking to an agent-side capability service cannot upload a file without standing up an HTTP endpoint it otherwise does not need.
Live latest-open sweep: checked latest 20 open issues at 2026-07-21 ~13:00Z; no equivalent found. A2A in-flight claim sweep (last 60 min): no competing claim.
The Problem
The field's state machine — ready → starting → uploading → processing → downloadable / not-downloadable → deleted, plus upload-failed / scan-failed — is transport-agnostic in shape, but every byte-moving step is bound to one implementation:
upload(file) constructs XMLHttpRequest + FormData inline (src/form/field/FileUpload.mjs:439-490).
deleteDocument() calls fetch(me.documentDeleteUrl) (src/form/field/FileUpload.mjs:633).
checkDocumentStatus() calls fetch(me.documentStatusUrl) (src/form/field/FileUpload.mjs:658).
- Progress is XHR
upload.progress events; abort is xhr.abort().
An app that wants upload-over-websocket today must fork the field or reimplement its UI/state machine — both wrong, since the state machine, drag-and-drop surface, validation (types, maxSize), and error vocabulary are the reusable parts.
The Architectural Reality
Precedent for a sub-namespace under a field: src/form/field/trigger/ (Base.mjs + concrete triggers). The same shape fits here: the field keeps state, vdom, and validation; a transport hierarchy owns the byte pipe.
The current implicit server contract (POST multipart → JSON with documentId; DELETE on discard; optional scan-status poll) is the XHR transport's contract — it should live on that transport, not on the field.
The Fix
Introduce a pluggable upload-transport seam:
- New sibling namespace
src/form/field/fileUpload/ with:
Transport.mjs — abstract base defining the contract: upload({file, onProgress}) → Promise<{documentId, …}>, abort(), deleteDocument(documentId), and optional checkDocumentStatus(documentId).
Xhr.mjs — the default transport: today's XHR/fetch logic extracted byte-for-byte, honoring uploadUrl, documentDeleteUrl, documentDeleteMethod, documentStatusUrl, headers, and the beforeRequest header-injection event.
- New field config
uploadTransport (class or instance; default Xhr). The field delegates upload / abortUpload / deleteDocument / checkDocumentStatus to it and keeps everything else.
- Progress and abort map onto the contract: transports report progress via callback and must honor
abort() semantics.
The WebSocket chunked transport itself (begin/chunk/commit framing, binary frames where supported) is a follow-up ticket once the seam exists.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
FileUpload.upload() mechanics |
current XHR impl at src/form/field/FileUpload.mjs:439 |
delegated to uploadTransport |
default Xhr transport = current behavior |
JSDoc on config |
existing field examples green |
new config uploadTransport |
this ticket |
class or instance of fileUpload/Transport |
fileUpload/Xhr |
JSDoc + field docs |
unit spec |
fileUpload/Transport contract |
this ticket |
abstract base, no impl |
subclass required |
JSDoc |
contract spec with a test-double transport |
uploadUrl / documentDeleteUrl / documentStatusUrl configs |
current field |
consumed by Xhr transport, unchanged |
unchanged |
unchanged |
existing examples green |
Decision Record impact
none — Body-side component API seam; no ADR governs form-field transports. Aligned with (not governed by) the ADR 0020 harness direction as motivation.
Acceptance Criteria
Out of Scope
- The WebSocket transport implementation (separate follow-up ticket)
- Any server-side reference upload service or protocol specification
- Changes to the field's state vocabulary, validation, or drag-and-drop surface
Avoided Traps / Gold Standards Rejected
- Welding ws-chunk framing directly into the field — binds one app's protocol into a shared component; the seam keeps framing per-transport.
- Mandating base64 in the contract — the transport decides framing; a ws transport may use binary frames.
- Widening to a generic "upload service" abstraction — the field's need is a byte pipe, not a service model; YAGNI until the second transport lands.
Related
- #13012 (Agent Harness epic — the motivating app class)
- ADR 0020 (
learn/agentos/decisions/0020-*.md) — harness direction; motivation, not authority
Origin Session ID: d8a51237-4fcc-4171-8071-a391da0be361
Retrieval Hint: "FileUpload XHR transport seam pluggable upload harness"
Context
Neo.form.field.FileUploadspeaks exactly one transport: XHR multipart (src/form/field/FileUpload.mjs:439-490), withfetchhardcoded for document delete (src/form/field/FileUpload.mjs:633) and scan-status polling (src/form/field/FileUpload.mjs:658). Progress, abort, and completion are welded to XHR events.That is fine for apps with an HTTP backend. It excludes a growing class of Neo apps whose only backend channel is a message transport — websocket or worker message port — where the "backend" is an agent-side service and there is no HTTP plane at all. The Agent Harness direction (ADR 0020,
#13012) increases the share of apps in exactly that situation: a harness-hosted app talking to an agent-side capability service cannot upload a file without standing up an HTTP endpoint it otherwise does not need.Live latest-open sweep: checked latest 20 open issues at 2026-07-21 ~13:00Z; no equivalent found. A2A in-flight claim sweep (last 60 min): no competing claim.
The Problem
The field's state machine —
ready → starting → uploading → processing → downloadable / not-downloadable → deleted, plusupload-failed/scan-failed— is transport-agnostic in shape, but every byte-moving step is bound to one implementation:upload(file)constructsXMLHttpRequest+FormDatainline (src/form/field/FileUpload.mjs:439-490).deleteDocument()callsfetch(me.documentDeleteUrl)(src/form/field/FileUpload.mjs:633).checkDocumentStatus()callsfetch(me.documentStatusUrl)(src/form/field/FileUpload.mjs:658).upload.progressevents; abort isxhr.abort().An app that wants upload-over-websocket today must fork the field or reimplement its UI/state machine — both wrong, since the state machine, drag-and-drop surface, validation (
types,maxSize), and error vocabulary are the reusable parts.The Architectural Reality
Precedent for a sub-namespace under a field:
src/form/field/trigger/(Base.mjs+ concrete triggers). The same shape fits here: the field keeps state, vdom, and validation; a transport hierarchy owns the byte pipe.The current implicit server contract (POST multipart → JSON with
documentId; DELETE on discard; optional scan-status poll) is the XHR transport's contract — it should live on that transport, not on the field.The Fix
Introduce a pluggable upload-transport seam:
src/form/field/fileUpload/with:Transport.mjs— abstract base defining the contract:upload({file, onProgress}) → Promise<{documentId, …}>,abort(),deleteDocument(documentId), and optionalcheckDocumentStatus(documentId).Xhr.mjs— the default transport: today's XHR/fetch logic extracted byte-for-byte, honoringuploadUrl,documentDeleteUrl,documentDeleteMethod,documentStatusUrl,headers, and thebeforeRequestheader-injection event.uploadTransport(class or instance; defaultXhr). The field delegatesupload/abortUpload/deleteDocument/checkDocumentStatusto it and keeps everything else.abort()semantics.The WebSocket chunked transport itself (begin/chunk/commit framing, binary frames where supported) is a follow-up ticket once the seam exists.
Contract Ledger Matrix
FileUpload.upload()mechanicssrc/form/field/FileUpload.mjs:439uploadTransportuploadTransportfileUpload/TransportfileUpload/XhrfileUpload/TransportcontractuploadUrl/documentDeleteUrl/documentStatusUrlconfigsXhrtransport, unchangedDecision Record impact
none— Body-side component API seam; no ADR governs form-field transports. Aligned with (not governed by) the ADR 0020 harness direction as motivation.Acceptance Criteria
src/form/field/fileUpload/Transport.mjs+Xhr.mjsexist; XHR logic fully extracted from the fieldOut of Scope
Avoided Traps / Gold Standards Rejected
Related
learn/agentos/decisions/0020-*.md) — harness direction; motivation, not authorityOrigin Session ID: d8a51237-4fcc-4171-8071-a391da0be361
Retrieval Hint: "FileUpload XHR transport seam pluggable upload harness"