Skip to content

Phase 5a: availability portal foundation (ADR-0029) - #13

Merged
wellivea1 merged 2 commits into
mainfrom
codex/phase5-availability-portal
Jul 31, 2026
Merged

Phase 5a: availability portal foundation (ADR-0029)#13
wellivea1 merged 2 commits into
mainfrom
codex/phase5-availability-portal

Conversation

@wellivea1

Copy link
Copy Markdown
Owner

Delivers slice P5-a of docs/portal-design.md: the read-only half of the public availability portal. Requests, messaging, and the live layer are P5-b..P5-d.

Nothing here is exposed. portal.enabled defaults to false; when it is false the daemon never opens the portal database, never builds a public handler, and never registers the owner's sharing routes — there is no /p/ path to probe. Public exposure stays prohibited until the §12 exposure gate passes, including an independent review that has not happened.

The boundary is structural, not a filter

The obvious implementation — a handler on the existing API that filters its output — fails the wrong way: a filtering bug is a health-data disclosure, and the private store handle is one dereference away.

Instead internal/portal owns a separate database and does not import store, readmodel, estimation, domain, or api. A test parses the package's own imports and fails if that ever changes. internal/portalbridge is the single inbound path, narrowing a PhaseEstimate to {version, windows[startAt,endAt,zoneId], generatedAt, horizonEnd, status}.

Honesty budget comes from measured data

Confidence labels are withheld deliberately. ADR-0022 measured the buckets inverted on real history — the High bucket hit 0.61 against Medium's 0.81 — so publishing one would misinform someone making a scheduling decision. Every state instead carries the measured uncertainty in plain language, marks itself stale past six hours, and withholds any current-state claim past a day, because an out-of-date "awake now" is worse than no answer.

Two defects only a browser could find

Driving the page in a real browser caught what the Go suite structurally could not:

  • An Origin-only CSRF gate refused every genuine login. Referrer-Policy: no-referrer makes browsers send Origin: null on same-origin form posts, per the Fetch standard. Unit tests set the header themselves, so they never saw it. Relaxing the referrer policy would have put link tokens into Referer headers, so mutations now gate on Sec-Fetch-Site with an exact Origin match as the pre-Fetch-Metadata fallback.
  • The state card's background shorthand reset its own background colour, letting the page canvas show through the gradient.

A CSS-generated "now" badge also became real markup so assistive technology announces it.

Verification

Canary values planted in device labels, observation ids, source record ids, task titles, correction ids, and the private share label are asserted absent from every public response, every response header, and the portal database file bytes including its write-ahead log. Also covered: the recursive DTO allowlist; byte-identical responses for unknown/expired/revoked links; immediate revocation killing live sessions and deleting the snapshot; cross-link session isolation; a nine-case Fetch-Metadata matrix; passcode backoff with no global lockout; the read limit and its window rollover; IPv6 /64 grouping; audit-key rotation; the hourly retention sweep; and routes-absent-when-disabled at both the daemon mux and the owner API.

gofmt, go vet, Linux and Windows server builds, the full Go suite, contract fixtures, npm run check:web, and the separate-project boundary guard all pass locally.

Known limits

  • Materialization runs synchronously inside sync push and erase. Fine at current snapshot cost; first thing to move to a worker if push latency becomes visible.
  • The portal database key is derived one-way from the daemon data key, so reading it never yields the private key, but the two are not independently rotatable.
  • The transactional outbox in design §2 is not implemented — P5-a keeps the store one-directional, and P5-b needs it.

🤖 Generated with Claude Code

wellivea1 and others added 2 commits July 31, 2026 01:11
Delivers the read-only half of the public availability portal, behind a
default-false portal.enabled. Requests, messaging, and the live layer are
P5-b..P5-d; nothing here is exposed.

The boundary is structural rather than a filter. internal/portal owns a
separate database and does not import the private store, read model,
estimator, or domain packages, and a test parses its own imports to keep
that true. internal/portalbridge is the single inbound path, narrowing an
estimate to windows, a generation time, a horizon, and a status.

Confidence labels are withheld on purpose: ADR-0022 measured the buckets
inverted on real history (High 0.61 hit rate against Medium 0.81), so a
published label would misinform. Every rendered state instead carries the
measured uncertainty in plain language, marks itself stale past six hours,
and withholds any current-state claim past a day.

Unknown, expired, and revoked links share one store-level error, so a
handler is never told which occurred. Passcodes are argon2id with a per
profile-and-source backoff and no global lockout an attacker could trigger.
Source throttling runs before link resolution, which is what keeps the KDF
from becoming a memory-exhaustion lever.

Driving the page in a real browser found two defects the Go suite could
not: an Origin-only CSRF gate refused every genuine login, because
Referrer-Policy: no-referrer makes browsers send Origin: null on
same-origin form posts, so mutations now gate on Sec-Fetch-Site with Origin
as fallback; and the state card's background shorthand reset its own
background colour. A CSS-generated "now" badge became real markup so
assistive technology announces it.

Canary values planted in device labels, observation ids, source record ids,
task titles, correction ids, and the private share label are asserted
absent from every public response, every header, and the portal database
file bytes including its write-ahead log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three follow-ups from re-reading the slice:

A snapshot that fails to decrypt rendered the same empty page as a link
with nothing materialized yet, and said nothing to anyone. The visitor
still sees the empty page, but a real read failure is now logged, because
only the operator can act on it. The profile id is opaque; the link token
is still never logged.

The session cookie computed MaxAge from time.Now while its expiry came
from the injected clock. The two agree in production, but a clock skewed
into the past yields a negative MaxAge, which browsers read as "delete
this cookie now".

Replace a hand-rolled integer formatter with strconv.Itoa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wellivea1
wellivea1 merged commit 1e00c2b into main Jul 31, 2026
8 checks passed
@wellivea1
wellivea1 deleted the codex/phase5-availability-portal branch July 31, 2026 05:18
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