|
1 | 1 | # Files: the upload endpoint, the reference and custody |
2 | 2 |
|
3 | 3 | Why `/upload`, the reference and the pending cycle are built the way they are. |
4 | | -The contract — what `IsPathFile` certifies, the rejection and limit tables, the |
5 | | -pending/promoted cycle and the border between path and reference — is in |
6 | | -[files.md](../files.md). |
| 4 | +The contract — what `FileHint` checks and which layer checks it, the rejection |
| 5 | +and limit tables, the pending/promoted cycle and the border between path and |
| 6 | +reference — is in [files.md](../files.md). |
7 | 7 |
|
8 | 8 | ## The two messages of a file default |
9 | 9 |
|
10 | 10 | The two [messages](../files.md#declaring-a-file-default) quote different things, |
11 | | -because they happen at different moments. The first is the core reading the |
12 | | -string as the author typed it, before anything has looked at the disk, so it |
13 | | -echoes that string whole. The second is FuncToWeb turning an already certified |
14 | | -path into the reference it will publish, and by then the directory is the part |
15 | | -that must not travel, so only the name is left. |
16 | | - |
17 | | -## Why the endpoint does not apply the field limit |
18 | | - |
19 | | -The `IsPathFile` limits belong to the parameter, and `/upload` does **not** |
20 | | -apply them. This is not an oversight: |
| 11 | +because two different layers say them. The first is the core reading the string |
| 12 | +as the author typed it, before anything has looked at the disk, so it echoes |
| 13 | +that string whole, directory included — the author is the one who wrote it, and |
| 14 | +it never leaves the process. The second is FuncToWeb turning that path into the |
| 15 | +reference it will publish, and by then the directory is the part that must not |
| 16 | +travel, so only the name is left. |
| 17 | + |
| 18 | +Neither of them touches the disk, and that is the honest shape of the check: the |
| 19 | +core compares an extension, FuncToWeb asks whether the name round-trips inside |
| 20 | +the storage directory. A default naming a file that is simply not there |
| 21 | +satisfies both. It is not a gap the build could close cheaply either — the path |
| 22 | +is read once, when the `WebFunction` is compiled, and a file that exists then |
| 23 | +can be gone by the time the first request arrives, so the answer that matters is |
| 24 | +the one the resolver gives on each execution. |
| 25 | + |
| 26 | +## Why `/upload` does not apply `FileHint.max_size` |
| 27 | + |
| 28 | +The `FileHint` limits belong to the parameter, and `/upload` does **not** apply |
| 29 | +them. The reason is *not* that something behind it will: nothing on the server |
| 30 | +weighs a stored file, so the endpoint is not standing aside for a verdict that |
| 31 | +comes later — no verdict comes. |
21 | 32 |
|
22 | 33 | ```text |
23 | | -max_upload_bytes operational ceiling of the endpoint, cuts early |
24 | | -IsPathFile.min/max_size parameter constraint, validated on invocation |
| 34 | +max_upload_bytes operational ceiling of the endpoint, counts the real |
| 35 | + bytes as they arrive |
| 36 | +FileHint.min/max_size parameter constraint, applied by the browser to the |
| 37 | + File it is holding |
25 | 38 | ``` |
26 | 39 |
|
27 | | -A request to `/upload` carries bytes and a reference. It does not say which |
28 | | -parameter that reference will end up in, and it cannot: the same reference |
29 | | -serves several executions, and inside a list, a union or a nested dataclass |
30 | | -there is no reliable way to point at the node. Applying the field limit there |
31 | | -would mean walking the schema a second time inside FuncToWeb, or trusting a size |
32 | | -declared by the browser. Neither is acceptable. |
33 | | - |
34 | | -It is not needed, because the field limit is checked twice without FuncToWeb |
35 | | -taking part: |
36 | | - |
37 | | -```text |
38 | | -the browser weighs the local File before uploading it (pytypehintweb) |
39 | | -the core weighs the real file when building (pytypehint) |
40 | | -``` |
41 | | - |
42 | | -The first saves the transfer; the second is the verdict, and it is the only one |
43 | | -a hand-written HTTP call cannot skip. |
44 | | - |
45 | | -The browser's part is a courtesy, and it works with exactly what it can know: a |
46 | | -freshly chosen `File` carries its `.size`, so it is weighed before anything is |
47 | | -uploaded and, if it is over the limit, no reference is minted and nothing stays |
48 | | -pending (a multiple selection with one bad file is rejected whole, not halfway). |
49 | | -A reference supplied from outside names a file the browser never saw: there is |
50 | | -no size to weigh, none is invented, and the server is not asked for one. A file |
51 | | -edited after being uploaded, an expired reference or a hand-written client all |
52 | | -end up in the same place: the core's measurement. |
53 | | - |
54 | | -So the split is: |
| 40 | +A request to `/upload` carries bytes and a reference, and nothing else. It does |
| 41 | +not say which parameter that reference will end up in, and it cannot: the same |
| 42 | +reference serves several executions, and it can feed two functions whose bounds |
| 43 | +disagree. Inside a list, a union or a nested dataclass there is no node to point |
| 44 | +at either. Applying the field limit there would mean walking the schema a second |
| 45 | +time inside FuncToWeb — a second validator alongside pytypehint's, with its own |
| 46 | +reading of the same atoms, drifting from it the first time the core changes. |
| 47 | +That is the one thing this layer has undertaken not to build. |
| 48 | + |
| 49 | +The two numbers are also different kinds of thing, and reading one as an |
| 50 | +implementation of the other is what produces the wrong expectation. |
| 51 | +`max_upload_bytes` is an operational policy of the endpoint: how much this |
| 52 | +server is willing to receive and keep, whatever it later turns out to be for. |
| 53 | +`FileHint.min_size`/`max_size` are part of the function's contract: what this |
| 54 | +parameter is prepared to work with. One belongs to the deployment, the other to |
| 55 | +the signature. |
| 56 | + |
| 57 | +So each layer applies what it can genuinely know. The browser holds a real |
| 58 | +`File` with a real `.size`, so it weighs it before anything is uploaded and, if |
| 59 | +it is outside the bounds, no reference is minted and nothing stays pending (a |
| 60 | +multiple selection with one bad file is rejected whole, not halfway). The |
| 61 | +endpoint holds bytes on the wire, so it counts them, stops mid-write, deletes |
| 62 | +the partial file and answers `413`. The core holds a string, so it reads its |
| 63 | +extension. Nobody stats the stored file. |
55 | 64 |
|
56 | 65 | ```text |
57 | 66 | upload too large for the server → /upload responds 413 |
58 | 67 | local File that breaks the bound → the form neither uploads nor sends it |
59 | | -opaque reference that breaks the bound → /upload accepts it, |
60 | | - /invoke responds 422 |
61 | | -file below the field's min_size → /invoke responds 422 |
| 68 | +opaque reference that breaks the bound → /upload accepts it, /invoke runs |
| 69 | +reference that names nothing in storage → /invoke responds 422 |
62 | 70 | ``` |
63 | 71 |
|
64 | | -The `422` is not a failure of the function: it comes from `schema.build()`, |
65 | | -after the resolver has turned the reference into a path, so the core measures |
66 | | -the real file. It is input validation, with the core's message: |
67 | | - |
68 | | -```text |
69 | | -{"error": "SchemaValueError: doc: file too small: 99 bytes, minimum 100"} |
70 | | -``` |
| 72 | +A reference supplied from outside names a file the browser never saw: there is |
| 73 | +no size to weigh, none is invented, and the server is not asked for one. A file |
| 74 | +edited after it was uploaded is in the same position, and so is a hand-written |
| 75 | +client. The size bound is simply not applied to any of them, and saying |
| 76 | +otherwise would be selling a guarantee this layer does not hold. |
71 | 77 |
|
72 | | -The same holds inside a list, a dataclass or a union, because `decode()` does |
73 | | -the walking and the core does the measuring. |
| 78 | +That is a deliberate boundary, not an open hole. An application that needs an |
| 79 | +authoritative rule about the size of stored content owns that rule: it can check |
| 80 | +it inside the function, which is the one place that has both the path and the |
| 81 | +contract in hand, or in whatever wraps the mount. FuncToWeb does not invent that |
| 82 | +policy on its behalf, and the one ceiling it does own, `max_upload_bytes`, it |
| 83 | +enforces on the bytes themselves, where no client can talk it out of the number. |
74 | 84 |
|
75 | 85 | ## Why the name rules are portable |
76 | 86 |
|
@@ -131,15 +141,36 @@ reference is what makes that decision genuinely yours —it never stops |
131 | 141 | resolving and it never changes underneath you— so a retention policy written |
132 | 142 | around it holds. |
133 | 143 |
|
134 | | -## Why the resolver speaks first |
135 | | - |
136 | | -Both check that the file exists, and neither is redundant: the resolver decides |
137 | | -whether the reference names something in the storage directory, and the core |
138 | | -then certifies the path that came out of it. Since the resolver speaks first, |
139 | | -the message you see on this path is the resolver's; the core's |
140 | | -`file does not exist` is left for what does not go through the resolver, such as |
141 | | -a default written in the signature, or for a file that disappears between one |
142 | | -check and the other. |
| 144 | +## Why existence belongs to the resolver |
| 145 | + |
| 146 | +The resolver is not the first of two checks; it is the only one. It decides |
| 147 | +whether the reference names something this server owns: a bare file name, a |
| 148 | +resolved path whose immediate parent is `UPLOADS_DIR`, and a file actually |
| 149 | +sitting there —under the bare name, or as a pending one it promotes on the way. |
| 150 | +What comes out is a local path that still carries the extension the reference |
| 151 | +had, which is what makes `FileHint(extensions=…)` work end to end. |
| 152 | + |
| 153 | +The core repeats none of it. It does not stat, it does not test |
| 154 | +`Path.is_file()`, and a path that is not there builds without complaint, so |
| 155 | +there is no verdict waiting behind the resolver to catch what it let through. |
| 156 | +That is precisely why the rule is written once, on this side, and why the same |
| 157 | +resolver serves both execution endpoints and the prefill: the alternative here |
| 158 | +is not redundancy, it is a gap. |
| 159 | + |
| 160 | +It also explains the shape of the message. Every rejection on this path is the |
| 161 | +resolver's —`File not found: <reference>`, or the storage refusal— and it names |
| 162 | +the file by its reference, because that is the only name the client ever knew. |
| 163 | + |
| 164 | +The one value that skips the resolver is a default written in the signature, |
| 165 | +since there is nothing to resolve yet; it is checked for belonging to storage |
| 166 | +instead, which is what makes it publishable as a reference, and its existence is |
| 167 | +settled like everyone else's, on the first execution. |
| 168 | + |
| 169 | +The resolved path is local, and it keeps its extension because storage is a |
| 170 | +directory of files. A resolver that returned an opaque key with no extension |
| 171 | +(`s3://bucket/key` and the like) would break `FileHint` outright — the core has |
| 172 | +nothing but that string to read. FuncToWeb does not have such a resolver, and |
| 173 | +this is the assumption anyone writing one would have to keep. |
143 | 174 |
|
144 | 175 | ## The author/client border |
145 | 176 |
|
|
0 commit comments