Dock dock.build · v0.5

Decisions & progress, as they happen

Build Log

Running record of what shipped, what we decided and why, and what's queued. This lives with the plan, not in the repo — strategy stays out of the codebase.

2026-06-13 · The launch gate: isolate, then moderate

The two non-negotiables before any hosted signups: untrusted bytes can't touch the app, and abuse has a path off the platform.

Origin isolation: serve artifact bytes off a separate host shipped

Published artifacts are arbitrary, untrusted HTML+JS. Served from the app's own origin they'd sit one same-origin hop from the session cookie. So raw content now serves from a dedicated sandbox host (DOCK_SANDBOX_URL): a host-guard splits the app, the sandbox host serves only /raw/* + /healthz, and the app host 302-redirects any /raw/* to the sandbox.

  • Redirect-based enforcement means zero web changes. Every iframe already points at the app origin; the redirect carries it to the sandbox automatically, so the isolation can't be forgotten at a call site. The iframe↔host postMessage bridge already used "*" targets, so it stayed correct cross-origin.
  • Self-host with no second host configured keeps serving from one origin unchanged — the gate is opt-in by env, never a setup tax.

Abuse: report, take down, reinstate, with an audit trail shipped

Anyone viewing can flag an artifact with a required reason (⚐ Report on the header). A workspace manager triages a Reports queue in Settings that surfaces only when there are open flags: take down sets removed_at and the artifact 410s everywhere (every raw + content route, plus X-Robots-Tag: noindex), or dismiss the report. Removed artifacts show a tombstone instead of the document; an owner can reinstate.

  • Take down, don't delete. The record is kept and the action is reversible — a takedown is a moderation decision, not a destruction. Every action (report, takedown, reinstate, dismiss) lands in an append-only audit log.
  • Two tables (report, auditLog) + a removed_at column, wired through all three store drivers with the compile-time parity guards — moderation state is enforced across SQLite and Postgres, not hoped.

Verification how we checked

  • 14 new API tests: report + reason-required, role gating on the queue, takedown 410s every route and clears open reports, reinstate restores, the audit log records each action. Full suite 115 api tests green.
  • The host-guard + redirect verified against a configured sandbox origin; the app host refuses to serve raw bytes and bounces to the sandbox.
  • Rebased onto the workspace-naming + Admin/Creator/Viewer roles work that landed alongside; typecheck + suite + Biome CI green before each merge.

2026-06-13 · Make it yours: browse, mobile, and scale

The library becomes a place you navigate, on any device, that stays fast as it grows.

Browse: a sidebar, favorites, tags, and search shipped

The library was a flat grid; now it's a place to find things. A collapsible main-menu sidebar — All artifacts, ★ Favorites, and a Tags section with counts — sits beside the grid (an icon rail when collapsed, an off-canvas drawer on phones). Per-user stars, workspace tags (added from the artifact header; click a chip to filter), and a search box.

  • Favorites and tags are two small junction tables modeled on the existing share table — no change to the artifact row, so the compile-time parity guards stayed quiet.

Search + pagination moved to the server decision

Client-side filtering is fine for a demo, not a real library. GET /v1/artifacts now does title search, tag/favorite filters, and keyset pagination (?cursor=&limit={ artifacts, next_cursor }, newest-first on created_at) in the database; a /v1/tags summary keeps sidebar counts accurate independent of the page.

  • Why keyset, not offset: it's the efficient, feed-shaped primitive that folders and the notification inbox will reuse — paginate once, reuse everywhere. "Basic" search stays title-only; relevance / full-text is a later layer (roadmap C3).

Mobile, end to end shipped

The whole product works on a phone now. The artifact viewer goes full-width and comments move into a slide-up sheet — opened to half height so the document stays visible above it: you can see the text you're commenting on, and tapping a quote scrolls to it without dismissing the sheet. The header reflows, the browse sidebar becomes a drawer, and inputs are 16px so iOS never zooms.

  • Verified by actually driving a 390px viewport through the loop — read, comment, reply, react, resolve, jump-to-text, version history, diff. The plan site (this one) got the same responsive pass.

@mentions, notifications, and hardening shipped

  • @mentions + an in-app inbox — mention a teammate (or an agent) in a thread and it reaches them in the app: the awareness layer riding on the webhook outbox.
  • Security + tooling — auth-secret enforcement, per-IP rate limiting, and webhook SSRF guards; Biome for lint+format with CI (biome ci + typecheck + tests per PR) and a nightly Playwright smoke; a redesigned split brand/form login.

2026-06-13 · @agent: agents as safe contributors

The signature demo, built on everything before it: @mention an agent, it proposes for review.

@agent in comments shipped shipped

@mention a registered agent in a thread and it proposes a change; a human approves on the rendered-experience review surface. The agent is just a principal with a scoped token at commenter rank — so it can propose all day but never publish directly. Every safety property falls out of the permission model; nothing new to trust.

  • Pull-based spine. The agent reads its work from GET /v1/agent/inbox with its token, acts through the existing reviews API, and acks. Zero new infrastructure — dead simple to wire to Claude Code headless or any harness. A mention still fires the comment.mention webhook, so push works too as a "wake up and check your inbox" nudge.
  • Rides what existed. Agents appear in the @mention directory like people; a mention of an agent lands in its inbox instead of a notification bell. It authors as itself (never spoofing a person), reads context and proposes with the same token. The marginal build was a registry, the inbox, and mention routing — the loop did the rest.

Bring your own brains decision

Dock is not the LLM provider. The org supplies the model: a provider key, their own agent endpoint, or (later) a metered hosted agent. Self-host stays free of model cost and nobody is locked to one vendor. The full design lives on the plan site at /agents.

Verification how we checked

  • 7 API tests: owner-registers / commenter-can't, agent in the directory, @mention → inbox, agent proposes (authored as itself, not live), can't-approve-own, ack clears it, non-agent rejected.
  • A 9-check end-to-end run against real Postgres: register → @mention → inbox → propose-as-agent → ack → still-awaiting-review.
  • Browser-verified the Settings flow (create → token shown once → list). Typecheck + suite + Biome CI green; rebased onto the @mentions/notifications + single-container work that landed alongside.

2026-06-13 · Reviews: the loop's missing half

Proposed versions + an approve flow. Some people publish directly; others propose for review.

Proposed versions + review flow shipped shipped

A proposed state on top of the permissions work: a commenter (or an agent) publishes a candidate that does NOT go live; an editor reviews and approves (it becomes current) or requests changes. The pull-request model, applied to artifacts. New propose action at commenter rank, so propose is cleanly separate from publish.

  • Approve the experience, not the diff. A version is a whole rendered document, so the review surface centers on the rendered output: the proposed version is served through the exact same pipeline as a live one, and the reviewer toggles Proposed ↔ Current to compare what users actually see. The line diff is demoted to a third tab (it's noise on rendered HTML). Proposals are just versions-not-yet-current, so serving + diff reuse the version path.
  • A real collaboration, both sides. The proposer says why (a message shown to the reviewer) and can return to a Proposals view to read feedback; the reviewer leaves a note when requesting changes or approving. A queue + history rail shows every proposal with its state and note, open first then decided.

Conflicts: a staleness guard, not a merge decision

Whole-document artifacts (often AI-regenerated wholesale) have no clean 3-way merge, and the reviewers aren't engineers — a merge-conflict UI would be the wrong tool. So instead of merging, Dock detects staleness: when a proposal's base version is behind what's now live, an "Out of date" banner appears and Approve becomes an explicit "Approve anyway?" confirm that spells out the consequence. An informed decision, never a silent clobber. Last-write-wins stays the model; the UI just makes it honest.

Verification how we checked

  • Core + API tests: propose→approve goes live, commenter-can't-approve, request-changes carries the note, withdraw, re-approve conflict.
  • An 8-check end-to-end run against real Postgres (isolated temp DB): propose → request-changes-with-note → approve goes live, including the feedback note and the proposal counts.
  • Full browser loop on desktop and mobile (the queue rail collapses to a dropdown on phones). Typecheck + suite + Biome CI green; rebased onto the favorites/tags browse work that landed alongside.

2026-06-13 · Phase A: permissions + sharing

Artifacts get a real authorization model, the groundwork for review flow next.

Per-artifact permissions + sharing shipped shipped

Some people publish directly, others should only propose. That distinction now exists in the data, behind a single choke point: can(actor, action, visibility) gates every write.

  • Roles viewer < commenter < editor < owner; actions read / comment / publish / approve / manage. A commenter creates content to be reviewed but cannot approve or publish; editors and owners do. This is deliberate: it makes "content someone has to review" expressible before reviews themselves exist.
  • Sharing is a per-artifact role override. An owner adds a teammate by email at a role from the artifact header; the share lifts (or lowers) just that person on just that artifact, without touching their workspace role. Everyone unlisted falls back to the workspace default, stated plainly in the popover.
  • Every mutating route (publish, restore, comment, react, edit/delete comment, webhook management) routes through can(); denials return 403. The artifact response carries my_role so the UI shows only what you can do.

"Both": workspace baseline + per-artifact override decision

Effective role resolves per-artifact override, then workspace membership, then a visibility floor. Not pure-RBAC, not pure-ACL: the workspace role is your baseline, a share is the exception.

  • Lazy provisioning: the first member of a workspace becomes its owner, everyone else joins at the configured default. A fresh secured instance has exactly one owner and needs no setup step.
  • Open instance stays open: with no static token configured, an anonymous caller is trusted as owner, so zero-config self-host keeps working unchanged.
  • Single workspace ("local") for now; existing artifacts already carried org_id='local', so there was nothing to backfill. Multi-workspace is a later, additive workstream.

A clean textual rebase is not a correct one lesson

Four PRs merged to main mid-build (comments, dock init, slides, the agent loop). The rebase applied with one trivial import conflict, looked done, and was still broken: main had added three comment routes calling a writeOk helper this branch deleted. Rule now: after any rebase that touches shared files, typecheck and run the suite before trusting the merge, and audit that new mutating routes are actually gated (a missing gate is invisible to the compiler).

Verification how we checked

  • 10 core unit tests for the can() matrix; 8 API integration tests (commenter blocked from publish, viewer read-only, a share lifting a viewer to editor, owner-only manage).
  • An 11-check end-to-end run against real Postgres in an isolated temp database, exercising the same scenarios through the pg driver.
  • Full browser roundtrip: an owner shares an artifact with a teammate by email and the member appears as editor. Typecheck + the full suite green across all seven packages before merge.

2026-06-12 · post-v1: notifications + data layer

First roadmap-B work lands: webhooks + Slack, on a cleaned-up data layer.

Webhooks + Slack shipped shipped

Notifications that reach you. Subscribe an endpoint or a Slack incoming-webhook to comment.created / comment.resolved / version.published.

  • Reliable by design: an outbox table + in-process worker, exponential backoff, dead-letter after 6 attempts, and a visible delivery log (status, attempts, last error) in settings. No queue service — the SQLite→Postgres ladder still holds.
  • Generic payloads are HMAC-SHA256 signed (X-Dock-Signature); Slack gets Block Kit messages. Canonical payload stored once, formatted per-kind at delivery, so it stays re-deliverable.
  • Arbitrary webhooks are the base primitive; Slack is the first first-party connector — same adapter shape covers Teams/Discord. Secrets never leave the server.
  • Verified end-to-end on both SQLite and Postgres: generic + Slack delivery, signature validation, event filtering, retry→delivered on a flaky endpoint.

Data layer: proper drizzle, enforced across writers decision

The store had drifted — Postgres was raw SQL from day one (a drizzle-orm version skew broke pg-core), SQLite mixed builder + raw, schema duplicated by hand. That drift had already caused real cross-writer bugs.

  • Root cause: drizzle's many optional peers (better-sqlite3 and pg in one package) spawned mismatched instances. Fixed with a pinned version + dedupe-peer-dependents → one instance.
  • All CRUD now goes through the drizzle query builder in pg/sqlite/d1 (dialect-correct SQL generated, not hand-written); analytics aggregations stay raw sql where they read clearer.
  • Compile-time parity guards tie each drizzle table to its core record type in both dialects: if a table drifts, or SQLite and Postgres disagree, typecheck fails. "Works across writers" is now enforced, not hoped.

2026-06-12 · the v1 push

Auth → deploy → TanStack Start → revision history → presence → analytics → sticky comments, in one run.

Roadmap to world-class published decision

The post-v1 plan is now a page of its own: Roadmap. Three phases (Teams & trust, The loop industrialized, Serve like a platform), a hard-problems register, and an evaluated concept list. Headline calls: proposed versions + review flow and @agent in comments are the value crown (the PR model for artifacts, with agents as safe contributors); a sandbox serving domain and an abuse/takedown story are non-negotiable before any hosted signups; realtime co-editing stays explicitly cut.

Comment anchoring is a served standard decision

Comments anchor to W3C TextQuoteSelectors, and the anchor client (highlight painting, two-way jump, selection capture) is a versioned, separately-served file at /raw/dock-client.js with a short cache — referenced by URL from artifact HTML, never inlined.

  • Why: artifact HTML is cached immutable. Inlining the client freezes old behavior into every previously-viewed artifact forever; serving it by URL means the comment experience can evolve independently of published content.
  • Resolution truth lives in the rendered document (inside the sandboxed iframe), not the raw source. The old server-side check compared rendered-text quotes against raw markdown/HTML and false-flagged anchors as broken whenever a selection crossed styled text.
  • Language: "orphaned" is banned. When anchored text no longer exists in the shown version, the UI says text changed and keeps the original quote visible, muted.

dock init + content standards queued

The anchor client is the first piece of a bigger idea: published content follows conventions, and Dock gives you the scaffold.

  • dock init scaffolds a publishable project: a dock.json (id, title, visibility, spa flag) so republish targets the same artifact without remembering ids.
  • Per-format standards so comments stay manageable: markdown and HTML conventions that keep text anchor-friendly (stable headings, no churn in inline markup), and the anchor-client contract documented as a public protocol.
  • Skills/instructions files in the scaffold so agents publish, read comments, and reply in-thread correctly by default — the agent loop as a first-class convention, not tribal knowledge.

Viewer analytics: a simple event table, on by default decision

  • One append-only view row per open, indexed — counts, uniques, 30-day timeline, per-version split, recent viewers. No heavy pipeline.
  • Logged-in viewers by name; guests tracked via an anonymous cookie id, shown as "Anonymous". Real uniques without storing identity.
  • DOCK_ANALYTICS=false is a one-line kill switch for self-hosters. Kept on SQLite too — feature parity beats driver special-casing.
  • Tenant-safe by construction: views hang off the artifact, which carries org_id.

Multi-tenant by default queued

Foundations already in place (org_id on artifacts, principal/acl tables). Plan: Better Auth's organization plugin → stamp org on publish → scope reads + analytics by active org → org switcher. A dedicated workstream, additive, no rewrite.

Stacked PRs + squash-merge don't mix lesson

Stacking PRs on each other's branches while squash-merging to main strands content in intermediate branches and re-conflicts every remaining PR after each merge. Rule now: every PR targets main; when a stack builds up anyway, consolidate to one PR and close the rest.

Shipped in this run shipped

  • Auth — Better Auth: email+password zero-config; Google + enterprise OIDC (Okta/Entra/Auth0) via env vars only.
  • Deploy — CDN web + API container + Postgres + S3/R2 (SigV4 client, AWS virtual-host by default), all env-driven; single-container SQLite path unchanged. Fly/Docker/CF Pages config + runbook.
  • Web — TanStack Start (SPA mode, static bundle, no lock-in); revision-history dropdown + in-UI diff; live library previews.
  • Live — presence avatars ("N viewing") over SSE; persisted viewer analytics with an Insights popover.
  • Comments — sticky: in-document highlights, click-to-jump both directions, re-anchoring across versions, resolve clears / reopen repaints.

All verified end-to-end on a local production-shape stack (Postgres + MinIO), every feature browser-driven before merge.

Also queued queued

  • Redis backplane — cross-instance SSE fan-out + shared presence once the API scales horizontally.
  • Event-table roll-up — analytics growth is unbounded by design for now; add prune/roll-up when it matters.
  • Hosted tier — a reachable deployment needs real infra (container host, Neon, R2, domain); the runbook is turnkey once credentials exist.