Skip to content

Phase 5b: visitor time requests through a transactional outbox (ADR-0030) - #14

Open
wellivea1 wants to merge 1 commit into
mainfrom
codex/phase5-availability-portal
Open

Phase 5b: visitor time requests through a transactional outbox (ADR-0030)#14
wellivea1 wants to merge 1 commit into
mainfrom
codex/phase5-availability-portal

Conversation

@wellivea1

Copy link
Copy Markdown
Owner

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.enabled defaults 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 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 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_request is 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

  • The desktop dialog for picking a block on the calendar is not built; an owner decides through /v1/portal/requests/{id}/decision today.
  • A visitor proposal is filed against an enrolled device, because proposals are keyed to a creating device and a visitor has none. With no enrolled device the request stays visibly queued.
  • Request limits are per session and per profile; a visitor with several sessions is bounded by the per-profile pending cap.

🤖 Generated with Claude Code

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant