Review answered — c8415ce on sharing. Both blockers fixed, ten of the twelve findings closed, five mutations run against the new guards and all five caught. Full suite green, gofmt -l empty, go vet clean.
Blockers
#2 — a reader was handed a live editor. Fixed. showPage.CanWrite from a new Store.CanWrite, which runs the same EXISTS clause writable builds — the one predicate asked twice, so the page and the update route cannot disagree. views/show.html gates the mount point, its data-sqid, and the script.
Absent rather than disabled, and the finding's own framing is why: the server refusal always worked, and it was never the point. The damage is upstream of it — the browser has taken the keystrokes, save-queue.mjs classifies the 4xx as permanent and drops the batch. A disabled editor is one CSS rule from being an editor again; an absent one has nothing to re-enable.
#1 — a grant made a full member. Fixed, option (a), as recommended. allowlist.configured() is membership; allows() stays admission. create, sharePost, shareRevoke and shareLink require the former. A guest reads and writes what they were granted and cannot create or share, so the transitive chain has no first step.
403 rather than 404 on those routes, deliberately: the app answers 404 for documents because §4.2 requires "not yours" to be indistinguishable from "does not exist", and there is no document here to hide. Pretending POST /documents does not exist would be a lie told for no gain.
#3 — revocation ended nothing. Fixed. requireAdmission re-asks allows() inside the RequireSession group. Members pay nothing — allows() answers from the configured set before touching the database — and a guest is exactly who the answer can have changed for. The HasGrant call behind it now carries a 2s timeout rather than context.Background(), since it is on every request.
The rest
- #4 —
{{define "title"}}onpublic.htmlandshare.html. Both rendered<title>Hello</title>. - #6 —
Cache-Control: no-storeandX-Robots-Tag: noindex, nofollowon/p/, and the comment corrected. You were right that absence is not a prohibition; the old comment claimed a guarantee it did not have. - #9 —
docRow.SharedNote("Shared with you"), and the sample library is now skipped for a guest (Store.seedOnlyMembers). An invitee arriving to seven documents was the outcome0005_grants.sqlsays the design avoids, deferred to sign-in rather than prevented. - #10 —
subjectOfdefers tonormalizeAddress;looksLikeAnAddressrejects nonsense; granting to yourself is refused. - #11 —
render.pageerror checked,Content-Typeset beforeWriteHeaderon the 400 path, theHasGranttimeout above. - #12 —
created_byrestored togrants. Your note is the one that landed hardest: the migration argues the nullabledoc_idat length and never mentions the column it dropped.HasGrantis now scoped to grants on documents that still exist, so a grant on a deleted or orphaned document stops holding the front door open. - #8 — the stale comments. Roughly a dozen still said "owner-scoped" about reader-scoped code, including
ApplyUpdate's andgetDocumentForUpdates's.
Tests
7a, 7b, 7c, 7d, 7f, 7g all added or fixed as specified, plus TestPublicRefusalsAreIndistinguishable (byte-equality across unknown / revoked / deleted-document, needing a new TestSoftDelete helper) and TestTheTokenIsNotStoredInPlaintext, which reads the table directly — the one assertion that cannot be made against behaviour.
7e is where it got interesting, and I owe you three more findings of the same class.
NewForTestWith exists now, and the end-to-end admission test with it. But TestSignIn reads mailRecorder.lastBody — the last mail sent to anyone — and a refused address gets the same 303 ?sent=1 a real one does. So it returned Alice's link and reported success for Bob. I added a recipient check. That was still wrong: a second refusal of an address that once succeeded still passes, because the recorder keeps the earlier mail. It now snapshots a send counter and requires a new send.
Second: NewForTest wires no BaseCatalog, so catalog strings render as their own keys. An assertion I had written minutes earlier — Contains(body, "published") — passed on the key docs.share.public.published whatever the page said. Both such assertions now key on the catalog key, with a comment saying why.
Third: the test I wrote to catch #2 first banned the bare string docs-editor, which appears a dozen times in that page's own <style>. It would have failed on a correct page and told me nothing about the element.
That is three, in one sitting, in tests written while fixing the ones you found. Your point about editor-entry was not a nit about one string.
Ruling 16
Adopted, and your reasoning replaced mine — see oficina/suite 55d7798. I had written "a database read reveals the tokens", which as you say is nearly worthless for Docs, where the same read reveals every document. The doctrine now argues the standing-key-versus-snapshot point and the re-display surface, records that the rule binds unsealed apps too, and states where it stops: a capability in a URL is in browser history and in an access log, and hashing at rest does nothing about either. It no longer claims to have solved that.
Mechanics as you specified: unsalted SHA-256, UNIQUE(token_hash), Publish returns the plaintext once. LinkOf is gone and IsPublished returns a bool — the ruling as a signature rather than a convention.
On the show-once collision with the 303: I took the one-time flash. oneShots (in memory, 30s TTL, keyed on person+document, removed on read) holds the URL across exactly one redirect. Rendering the POST response directly would have made a reload rotate the link the person is mid-copy of, which is the worse of the two failures. In memory is consistent with hub and previews, and a restart between POST and GET loses the URL — the owner republishes, which is the same recovery as losing it any other way, and better than writing the plaintext token down.
Not done
#5, caching the replay behind /p/. You marked it non-blocking and I have left it: it is a performance change to a hot path, it wants its own tests, and bolting it to the tail of this branch is how it would ship unreviewed. Filed as the next piece of work rather than forgotten.
The citation correction you raised in the addendum. publish.go, links.go and 0006_links.sql still cite suite ruling 15 for the read-only rule. You are right that it should cite Docs §8.2 as the authority with ruling 15 as context — the branch implements the settled rule and cites the contested proposal for it. Not yet changed, because ruling 15's status is live in oficina/suite and I would rather correct these citations once, against whatever it settles as, than twice.