From ece86cb2592bd55da0a7421eeb021dba5fcf23b4 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 23 Sep 2026 12:51:20 -0400 Subject: [PATCH] docs(write-pipeline): say when uploadBlob judges a blob It is judged before its body, at checkpoints while the body arrives, and once it is in, and a body that differs from its Content-Length is refused. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I55d645c76450e07b571cb151e02e1f7a2bb49e5f --- docs/write-pipeline.md | 33 ++++++++++++++++++++++++++------- plan/policy.md | 18 +++++++++++------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/docs/write-pipeline.md b/docs/write-pipeline.md index 6aacf7d5..21ae899f 100644 --- a/docs/write-pipeline.md +++ b/docs/write-pipeline.md @@ -257,20 +257,39 @@ stateful evaluator than the same writes sent one at a time. ## Where a blob upload is judged `uploadBlob` is not a record write and has no diff, so it does not pass -through the stages above. It is judged under `blob.write`, twice. +through the stages above. It is judged under `blob.write` before its body, +while the body arrives, and once it is in. The first judgment is on what the request claims — the account, the type the uploader declared and the `Content-Length`, if it sent one — before a byte of the body is read, so a blob a policy refuses for its type or its size is -never carried. The second is once the bytes are in and before they are -stored, and adds the type read from the blob itself and the size that -actually arrived. What the first judgment does not know is absent rather than -guessed, and absent facts do not deny. +never carried. What it does not know is absent rather than guessed, and +absent facts do not deny. + +While the body arrives, the upload is judged again once 64 KiB is in, and +again each time what has arrived doubles. These judgments add the type read +from the first bytes. Their size is the `Content-Length`, or the bytes +received so far when the request sent none, so a statement that refuses +small blobs can refuse a large one sent with no `Content-Length` before the +rest of it arrives. With no `Content-Length`, a body a policy refuses for +its size stops at the first frame that carries it past twice the policy's +limit, or past 64 KiB when that is more. A large upload costs a few +judgments, not one per frame. None of these judgments admits the upload: +each refuses it or lets it go on. + +The last judgment is once the bytes are in and before they are stored. It +has the type read from the blob and the size that actually arrived, and its +answer is the upload's. An evaluator observes the upload once, at the +judgment that refuses it or at this one. + +A body whose length differs from its `Content-Length` is refused as +`InvalidRequest`, as atproto's blob specification asks, and one that runs +past its length is refused at the frame that does. A refused upload is discarded. An upload presented by an OAuth token names its client, and its declared type is checked against that token's `blob:` -scope before either judgment. A `blob:` scope names types and not sizes, so -what bounds a body is this judgment and the server's blob limit. One +scope before any judgment. A `blob:` scope names types and not sizes, so +what bounds a body is these judgments and the server's blob limit. One presented by the account's own token names no client and is not scoped. diff --git a/plan/policy.md b/plan/policy.md index 54b21c64..da19c6db 100644 --- a/plan/policy.md +++ b/plan/policy.md @@ -571,15 +571,19 @@ a write. - [x] **A blob upload is judged, under `blob.write`.** The account, the type the uploader declared, the type read from the blob's own bytes and its - size are a `didbot_policy::Subject::Blob`. It is judged twice: on what - the request declares, before the body is read, so a blob a policy - refuses is never carried; and again once the bytes are in and before - they are stored (`didbot_pds::provision`'s `JudgedUpload`). A fact the - first judgment lacks is absent rather than guessed, and cannot deny. A - refusal discards the upload and answers `PolicyViolation`. The regex engine's `denyBlobUnless` holds an upload + size are a `didbot_policy::Subject::Blob`. It is judged on what the + request declares, before the body is read, so a blob a policy refuses + is never carried; again at checkpoints while the body arrives, which + can refuse it but not admit it, so a size policy stops a body with no + `Content-Length` soon after it passes the limit; and once the bytes + are in and before they are stored (`didbot_pds::provision`'s + `JudgedUpload`). A fact the first judgment lacks is absent rather than + guessed, and cannot deny. A refusal discards the upload and answers + `PolicyViolation`. The regex engine's `denyBlobUnless` holds an upload to the types and the size an operator names, and Cedar's `blob.write` action takes the same facts. Tests: - `crates/didbot-serve/tests/blob_policy.rs`. + `crates/didbot-serve/tests/blob_policy.rs` and + `crates/didbot-pds/tests/blob_checkpoints.rs`. - [x] **Record which policy version admitted each write, and which app made it.** The system exists for accountability, and "was this write legal -- 2.51.2