test(posts): RED cycle 2 — blob ownership, contract fixes, T2 probe rewrite master
Task 6 RED cycle 2. Two contract fixes green, eight blob pins red, one T2 arc rewritten around the ceiling the flip moved. CONTRACT FIXES (both green) declaredRoutes gains post.update, declared exactly like post.create beside it — same guard, no limiter of its own — because it is the same kind of write by the same principals, and an edit cheaper to call than the post it edits would be the obvious way to spend a quota the create path meters. The block comment's "two writes" becomes three. AggregatorQuotaStopsTheEleventhPost submits ten DISTINCT titles. Its premise predated content-derived rkeys: ten identical submissions used to produce ten records because each got a fresh clock TID, and now converge on ONE record by design — the second through tenth find their own post standing and are handed back its URI. The quota still reached ten, so the old loop asserted an eleventh refusal while exactly one post existed. BLOB PINS — the two plan-review blockers, red write side (service_blob_test.go, T1): a post whose unfurled link preview produces a thumbnail uploads that blob to the AUTHOR's PDS. Asserted with com.atproto.sync.listBlobs on both repos, because the record only names a CID: a blob in the community's storage produces a record that looks identical and resolves for nobody, can be garbage-collected by a repo that references it nowhere, and is not the author's to release when they delete the post. Today's red is the PDS itself refusing the record — "Could not find blob" — which is the invariant being enforced by the one participant that cannot be fooled. Three guard-survival pins accompany it and pass now and after: the 6MB cap, the image/* allowlist and an unreachable origin each leave the post intact and the author's storage empty. UploadBlobFromURL is a choke point on an attacker-influenced fetch (a client picks the page, the page picks the thumbnail); moving it onto the author's credentials must not lose it. read side (blob_transform_test.go, T0 + one T1): a postv2 record's blobs resolve against the AUTHOR's DID+PDS while deprecated community.post records keep the COMMUNITY owner. One table holds both kinds and one read path serves them, so the owner is chosen per record from the URI's collection; getting it wrong builds a well-formed URL naming a repo that has never held the blob, which is a broken image and a silent server. Fixtures that used to carry no URI now name their collection explicitly — an owner chosen FROM the URI cannot be tested with fixtures that lack one. Fails closed: a postv2 record with no author is left unprojected rather than falling back to the community. The T1 half pins what a pure test cannot — AuthorView.PDSURL is populated on the real read path. The repository has always SELECTed users.pds_url to hydrate the avatar and always dropped it; correct owner selection over a field nobody fills in resolves every postv2 post's media against an empty host. AuthorView.PDSURL added as the rule-7 stub (mirrors CommunityRef). T2 PROBE (tests/e2e/post_admission_contract_test.go) The admitted arc's ceiling moved and the arc moved with it. It used to stop at the community-credential refresh and assert an unclassified 500; a post is written to its author's repo now, so it stops at the author-repo open and asserts the named 503 NoAuthorCredentials. That is a strictly better probe. A 500 was the absence of a classification — a nil-pointer three layers down would have answered it just as readily. The named 503 is produced at exactly one place in the write path, so reaching it proves the request travelled past admission, past classification, past the ledger reservation, to the author-repo open. Any 4xx now means the gate refused something the community authorized; any 500 means something unclassified broke. The never-409 retry property is unchanged but was RE-VERIFIED rather than assumed: the release now happens at a different step, and a reservation released on the old one but not the new one would look identical from every other tier. The other three arcs were checked against the flip and left alone — the 401 aggregator gate, the 404 community resolution and the 403-twice quota-preservation arc all run before the author-repo open, so their wire truth is untouched. FINGERPRINT NOTE A comment at the matrix test's fingerprint fixture recording why it still hashes a PostRecord: the boundary converter keeps the value BYTE-STABLE across the deploy, and retyping it would repartition every live post_submissions row — an author mid-retry when the binary rolls would miss their own reservation and be admitted as a second post, reintroducing the exact duplicate the deterministic rkey exists to close. Task-8 revisit pointer included: re-materialization is the one moment the change is free. go vet clean under all four tag sets; test-audit 0 violations; the full T0+T1 tree green apart from the three blob pins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>