Phase 5b: visitor time requests through a transactional outbox (ADR-0030) - #14
Open
wellivea1 wants to merge 1 commit into
Open
Phase 5b: visitor time requests through a transactional outbox (ADR-0030)#14wellivea1 wants to merge 1 commit into
wellivea1 wants to merge 1 commit into
Conversation
…030) Someone holding a share link can now ask for a time. The request lands in the owner's existing proposal queue and the decision comes back to a visitor-visible status. This is the first boundary in the project an outsider may write across, so the mechanics matter more than the feature. The stores are separate by design, so filing a request spans two databases and cannot be one transaction. A transactional outbox with a stable idempotency key carries it: the portal stores the request durably, a bridge files exactly one proposal, and only the acknowledgement moves the request from `queued` to `pending`. Until then the visitor is told "saved and on its way", never "sent" — a status they cannot verify is not one to claim on their behalf. Four consecutive pumps still produce one proposal, which is the failure that actually happens: the private commit succeeds and the ack is lost. The visitor asks for a range; the owner must answer with a time. Approval therefore names an exact block inside the requested window, of exactly the requested length when one was given. A shorter block silently changes the ask and a block outside the window is a counteroffer, which v1 does not have. The generic decision route now returns 409 for a visitor proposal rather than quietly skipping both the slot rule and the delivery. place_visitor_request is deliberately absent from the assistant action registry. An agent able to mint a visitor-origin proposal could manufacture social pressure from a person who does not exist. A decline carries no reason, ever: any reason would disclose sleep, calendar, or health. An approval does reveal the chosen block, and the owner is told that before choosing rather than after. Authorship is a separate secret from the link, delivered in a URL fragment that browsers never send and proxies never log, with a one-time recovery code for the no-script path. Holding the shared link and passcode is not enough to read another visitor's request; a wrong secret and an unknown request return identical responses. Also fixes a P5-a defect this slice exposed: CSRF tokens were stored only as a hash, which a server-rendered form can never embed, so the mechanism could not have worked. They are now derived from the session under a server-held key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers slice P5-b of
docs/portal-design.md, on top of the P5-a foundation merged in #13. Someone holding a share link can now ask for a specific time; the request lands in the owner's existing proposal queue and the decision returns to a visitor-visible status.Still not exposed:
portal.enableddefaults false, and the §12 exposure gate — including an independent review — remains unmet.This is the first boundary an outsider may write across
So the mechanics matter more than the feature.
The stores are separate by design, so filing a request spans two databases and cannot be one transaction. A transactional outbox with a stable idempotency key carries it: the portal stores the request durably, a bridge files exactly one proposal, and only the acknowledgement moves it from
queuedtopending. Until then the visitor is told "saved and on its way", never "sent" — a status they cannot verify is not one to claim on their behalf. Four consecutive pumps still produce one proposal, which is the failure that actually happens: the private commit succeeds and the ack is lost.The visitor asks for a range; the owner must answer with a time. Approval names an exact block inside the requested window, of exactly the requested length when one was given. A shorter block silently changes the ask; a block outside the window is a counteroffer, which v1 doesn't have.
The generic decision route now returns 409 for a visitor proposal. It can discharge neither the slot obligation nor the delivery obligation, so leaving it able to decide one would have been a silent way to skip both.
place_visitor_requestis absent from the assistant action registry. An agent able to mint a visitor-origin proposal could manufacture social pressure from a person who does not exist, and the user would have no way to tell.What a visitor is and isn't told
A decline carries no reason, ever — any reason would disclose sleep, calendar, or health. An approval does reveal the chosen block, and the owner is told that before choosing rather than after.
Authorship is a secret separate from the link: a 256-bit value delivered in a URL fragment (never sent to a server, never in a proxy log), plus a one-time recovery code for the no-script path. Holding the shared link and its passcode is not enough to read another visitor's request — a wrong secret and an unknown request id return identical responses.
Handle and message are encrypted in the portal store, encrypted again inside the owner's proposal, shown to the owner because judging a request requires them, and absent from every projection, audit row, and log line.
No product cap on how far ahead someone may ask (owner decision 3) — a window past the forecast horizon is accepted, flagged, and shown with an explicit statement that the estimate cannot reach that far. What is bounded is the window's length.
Also fixes a P5-a defect this slice exposed
ADR-0029 stored only a hash of the synchronizer token, which a server-rendered form can never embed — the mechanism could not have worked. CSRF tokens are now derived from the session value under a server-held key, so the plaintext is recoverable on every render while staying unguessable.
Verification
Round trip, honest queued state under bridge failure and recovery without resubmission, idempotency in both directions, the six rejected slot shapes plus the accepted one, one-use tokens, the 409 guard, requester isolation, indistinguishable wrong-secret/unknown-request responses, the validation matrix, control-character sanitizing, the daily cap, revocation closing open requests, and a decline asserted free of "asleep", "busy", "calendar", "conflict", "medication", "because".
The whole visitor path was also driven in a real browser: passcode gate → dashboard → request form → submission → one-time code → status page, with the secret confirmed to appear only after the
#.gofmt,go vet, Linux and Windows server builds, the full Go suite, contract fixtures,npm run check:web, and the boundary guard all pass locally.Known limits
/v1/portal/requests/{id}/decisiontoday.🤖 Generated with Claude Code