Append-only. Newest at the bottom. Each entry: what was decided, why, and what was rejected. Do not re-litigate settled decisions — supersede them with a new entry that references the old one.
Single-user work tool for building products. Personal user, professional domain. Rejected: any life-admin/GTD framing.
Initiative → Product → (Repo | Arc) → Issue. The pain with Linear/Jira/GitHub
Issues is vocabulary mismatch, not missing features. The tool's nouns must match
the owner's mental model exactly.
Belongs to a product; groups issues from anywhere under that product (including its repos). Rejected names: "epic" (baggage), "project" (overloaded), "feature", "milestone", "track".
Backlog / Todo / In Progress / In Review / Done / Canceled — one global set, not configurable. Rigidity is a feature. Rejected: per-product status sets, custom workflows.
A repo is both a sub-container of a product and a pointer to an actual git repository. No GitHub Issues sync (non-goal). PR/commit linking IS in v1 via webhook magic words (issue key in branch/commit/PR), without status automation.
PREFIX-n (e.g. PROG-123). Keys survive moves within a product; cross-product
moves re-key with a permanent alias/redirect. Rejected: global sequence, no IDs.
Filterable by initiative/product/repo/arc/tag/priority. Per-container boards deferred — filters cover them. Kanban before sprints; sprints deferred entirely.
Priority (Urgent→None), estimate (points), tags, comments + auto activity feed. Due dates deliberately omitted from v1: sizing matters more than deadlines here.
Responsive web UI that works on a phone. Cut from v1 instead: sprints, multi-user, notifications.
Fits existing Cloudflare tooling, near-zero hosting cost, easy webhooks. Bun for packages/scripts, TypeScript strict, ESM. Rejected: Node server on a VPS, full-stack framework + managed Postgres.
Owner's hard requirement: snappy as heck, never Jira-laggy. React chosen for familiarity + ecosystem, conditional on the Linear pattern: whole workspace loaded into a client store, optimistic mutations everywhere, zero interaction spinners (see SPEC §8.2). If an interaction can't be made instant, the architecture is wrong, not the requirement.
Zero-trust in front of the app; no auth code in v1. Webhook route bypasses Access and verifies GitHub's HMAC instead. Rejected for v1: built-in login, GitHub OAuth (revisit when multi-user arrives).
users table with one row; creator/assignee/author FKs everywhere. Avoids a
rewrite when collaborators arrive, costs almost nothing now.
Confirmed, matching the repo directory.
One package, one Vite dev server: @cloudflare/vite-plugin runs the Hono
Worker (with a real local D1 via Miniflare) inside vite dev, so client and
API share a port and deploy as one Worker (SPEC §8.1). Layout:
src/worker (Hono) / src/client (React) / src/db (Drizzle schema), with
split tsconfig project references because Workers and DOM type globals clash.
Rejected: monorepo with separate api/web packages (overhead with no payoff for
one Worker); create-cloudflare template scaffold (hand-rolled instead so
every file is understood and documented in docs/SETUP.md).
src/db/schema.ts is the single schema source of truth. drizzle-kit generate
emits SQL into drizzle/ (committed); wrangler d1 migrations apply runs them
locally and, later, in production. Seeds live in scripts/seed.sql and are
idempotent. Rejected: drizzle-kit push (no migration history) and hand-written
SQL migrations (schema and SQL would drift).
(Open question #4 — client store library — remains open; it gets decided when the store is actually built in milestone 2, with the latency spike from SPEC §9.)
An issue's container is a product or one of that product's repos, never both,
never neither (SPEC §3). Modeled as a non-null product_id plus a nullable
repo_id that narrows the container: repo_id IS NULL means product-level.
Filtering/grouping by product — the dominant query — never needs a join, and
"move between product-level and repo-level" is a one-column update. The
invariants SQLite can't express cheaply (repo_id belongs to the same product;
arc same-product) are API-enforced. Rejected: polymorphic
container_type/container_id (loses FK integrity, every product rollup needs
a join); separate junction table (overkill for a 0..1 relationship).
The display key is product.key_prefix + '-' + number; only number is stored
(unique per product). A prefix rename therefore re-keys everything consistently
with zero data migration. Retired keys from cross-product moves are stored
verbatim in issue_key_aliases (text PK, e.g. PROG-123) since aliases must
survive any later prefix changes. Per-product sequence is a
next_issue_number counter on products, incremented transactionally on
create and cross-product move — fine at single-user write rates.
- IDs: app-generated text with a type prefix (
usr_,ini_,prd_,rep_,arc_,iss_,tag_,cmt_,act_) — identifiable on sight in URLs/logs. - Timestamps: unix-epoch integers set by the API (not DB defaults), Drizzle
mode: "timestamp". - Fixed vocabularies (status, priority, estimate points) live as
as constarrays inschema.ts; status/priority are Drizzle text enums, estimate is API-validated. - Archive everywhere: nullable
archived_aton all four container types; no hard deletes. - No manual board ordering column in v1: SPEC §4 specifies sorting/filtering by fields, not hand-ordering. Add later via migration if it earns its way in.
- Git PR/commit link tables are deferred to the webhook milestone;
activityrows carry a JSONdatapayload, so linked-PR events already have a home.
GET /api/workspace returns users, all containers, issues, tags, issue-tag
links, and key aliases in one D1 batch(). Comments and activity — the only
unbounded-growth tables and not needed for boards/lists — load per issue when
an issue page opens. Keeps the load-everything payload small for years of use
(SPEC §8.2).
Decided by the latency spike, not taste. Method: 5,000-issue synthetic
workspace (bun run db:seed:scale, deterministic), two prototypes rendering
an identical 6-column board of all 5k issues with one optimistic mutation
(status cycle, real PATCH + rollback), driven by 100 real DOM clicks in
headless Chromium with double-rAF click-to-paint timing and React Profiler
commit timing.
Results (p50/p95 click-to-paint): TanStack Query 23ms/98ms vs bespoke
normalized store with per-issue/per-column useSyncExternalStore
subscriptions 73ms/128ms. The bespoke store's theoretical advantage
(1 card + 2 column renders per mutation vs ~490 card renders) did not
materialize as latency: TanStack's structural sharing kept commits at ~17ms
vs 27ms, and total work fit in fewer frames. Both correct under a
column-movement check; both well under the perceptibility bar at realistic
board sizes (the 5k-card all-columns view is the worst case; the default
board hides Backlog per open question #2).
TanStack also brings rollback/retry plumbing, devtools, and per-issue query
caching (comments/activity pages) for free, and eliminates the
subscription-bookkeeping code the bespoke store needed. Whole workspace lives
in one ['workspace'] query with staleTime: Infinity; mutations are
setQueryData snapshot/rollback writes.
- Routing: wouter. First multi-view need; ~2kB hook-based router beats
hand-rolling history plumbing and react-router's weight for three route
shapes. URLs are key-based (
/issue/PROG-2) — stable, human-meaningful, and alias-redirectable. - Key resolution is client-side. The store already holds every issue and
alias, so
/issue/:keyresolves from memory: current keys first, thenissue_key_aliaseswith areplaceStateredirect to the canonical key — no server round trip, honors SPEC §3 permanent redirects. - Markdown: react-markdown for descriptions and comments (safe by
default, no raw HTML). Typography is a small hand-rolled
.prose-litestylesheet rather than the Tailwind typography plugin. PATCH /api/issues/:idgeneralizes the status endpoint to validated field patches (title, description, status, priority, estimate). A status change atomically appends astatus_changedactivity row (D1 batch), so the timeline is server-truth; the client invalidates the issue's timeline query after a successful status sync.- Timeline = comments + activity interleaved client-side from one
GET /api/issues/:id/timeline(two batched selects), per D20.
- Drag-and-drop: @dnd-kit/core. Headless, touch-capable, ~10kB; native
HTML5 DnD can't do smooth previews or touch. Cards are draggables, columns
are droppables, drops call the same
setIssueStatusas everything else. A 4px pointer activation threshold keeps plain clicks navigating to the issue page. No sortable within columns (D19: no manual ordering). - Filters live in URL query params (
/?product=…&arc=…&backlog=1), so any filtered board is bookmarkable — this is what makes deferring per-product/per-arc boards viable (SPEC §4); container pages link to their own filtered board view. Repo/arc filter options narrow to the selected product. - Backlog hidden by default behind a toggle (open question #2 default adopted). Done and Canceled columns always render.
- Container pages are one component parameterized by type; scope and child links derive from the workspace in memory. Inline list edits reuse the optimistic template. Container description editing is deferred (needs container PATCH endpoints; not part of this pass).
POST /api/issuesallocates the issue number with an atomicnext_issue_numberincrement on the product (D18). A crash between allocation and insert leaves a number gap — harmless, numbers only need to be unique and monotonic, not dense.- Creation is optimistic including the key: the client allocates the
number from the store's
nextIssueNumbermirror, valid because this client is the only writer in v1. The temp row is swapped for the server row (same key, real id), so creating can navigate to the new issue page instantly with zero spinner. The create dialog defaults its container from wherever the user is (container page, viewed issue's container, or the board's active filters) and defaults status to Todo, not Backlog — a freshly created issue should be visible on the default board, which hides Backlog. POST /api/issues/:id/move(SPEC §3): within a product the key and arc survive and onlyrepo_idchanges. Cross-product moves re-key from the target's sequence, clear the arc, write the retired key toissue_key_aliases, and append amovedactivity event (with from/to container ids, and old/new keys when re-keyed). The client mirrors all of it optimistically — including the local re-key and alias append — so an open issue page redirects to the canonical key with no round trip. Rollback restores exactly what the move touched.
- Hand-rolled palette, no dependency (
cmdkrejected: the dependency budget is tight per SPEC §8.2 and case-insensitive substring matching is plenty at single-user scale). Root mode searches issues by key — retired alias keys included, via the samefindIssueByKeythe router uses — or title, containers by name, plus commands. - Single-key actions open the palette in a picker mode (status / priority / estimate / move) scoped to one issue, rather than bespoke menus: one component, one interaction grammar, every picker filterable.
- Key map (SPEC §4 "decided during build"):
⌘K/Ctrl+Kpalette ·Ccreate issue ·Sstatus ·Ppriority ·Eestimate ·Mmove. Plain keys are suppressed while typing in any input/textarea/select. - "Current issue" for single-key actions = the issue page's issue, or
on boards/lists the card/row under the pointer or holding keyboard focus,
tracked by document-level event delegation on the
data-issue-idattributes the views already render — no per-component wiring. - Cross-cutting cleanup: status/priority display names moved to one shared
src/client/labels.ts(palette, board, and pages had three copies).
- POST + PATCH for all four container types. PATCH covers name,
description,
archived(boolean, mapped toarchivedAt), pluskeyPrefixon products (letters-only 2–8, globally unique — rename is safe because keys are derived, D18) andgitUrlon repos. - Container ids may be client-generated. Container pages are
id-addressed, so optimistic create + instant navigation requires the id
to survive reconciliation. The store generates
prd_<uuid>-style ids and the server accepts well-formed ones verbatim (single-user; the server still validates shape and falls back to its own id). Issues didn't need this — they're key-addressed and the key is computable client-side. - Archive = out of navigation, not out of existence. Archived containers disappear from board filter dropdowns, create-dialog targets, move targets, and palette search; their issues remain visible everywhere (nothing silently vanishes from the board), and parent container pages list archived children dimmed so unarchive stays reachable.
- Editing UI: shared
InlineEdit(single-line; Enter commits, blur/Escape cancels) andEditableMarkdown(extracted from the issue page) cover container names, prefixes, git URLs, descriptions, and issue titles.
- Auto-color: fixed 7-color palette in
shared/constants.ts, picked by a stable name hash — shared by server and client so optimistic rows match. - One endpoint assigns and creates:
POST /api/issues/:id/tagstakestagId(existing) orname(create-or-get, then assign) so the palette's "Create tag 'x'" is one atomic call.DELETE …/tags/:tagIdunlinks; tag rows are never deleted in v1. - Picker = palette mode T: toggles stay open for multi-tag editing; the
arc picker (mode A) follows the same pattern with the same-product
constraint enforced in
PATCH /api/issues/:id(arcId). Keyboard map from D25 extends toT(tags) andA(arc).
Recorded as SPEC §11 (per owner, 2026-06-12): issues should be executable work orders. Three pieces — a deterministic per-issue context bundle endpoint, an MCP server on the Worker for inbound "work on PROG-123" interrogation/updates from Claude Code, and an outbound kickoff (CLI handoff first, cloud sessions later) that works in a branch named from the issue key so §5 webhook linking closes the loop automatically. Roadmap re-prioritized: webhook milestone next (it's a prerequisite for the loop), then deploy/dogfood, then bundle + MCP, then outbound kickoff. "API for third-party clients" is hereby promoted from deferred to planned-v1.x (as the MCP surface). Implementation decisions deferred to the build.
- Two link tables (the ones D19 deferred):
pr_linkswith mutable state/title,commit_linksimmutable. Composite PKs (issueId + repo + number,issueId + sha) double as the idempotency guard — GitHub redeliveries are no-ops by construction. Rejected: one polymorphic links table (PR state updates would be awkward), storing full commit messages (subject line is all display needs). githubRepois"owner/name"text, not an FK torepos— links must survive container renames/archives, and deliveries can come from repositories that aren't (or aren't yet) containers in Progress.- Magic-word semantics: candidates
\b[A-Za-z]{2,8}-\d+\bresolved against current keys then aliases (server-side mirror offindIssueByKey); unresolved prefixes drop out, so prose like "UTF-8" can't false-positive. Branch-name keys link every commit in the push; message keys link their commit; PR keys come from title + body + head branch. Links are permanent — editing a mention away later doesn't unlink (matches the alias philosophy: references never break). - Activity:
pr_linked/commit_linkedrows only on first sight of a link; PR state changes update the link row silently (the state badge is the display, not the feed). New event types render on the issue page's timeline; links themselves load with the per-issue timeline endpoint (same unbounded-growth reasoning as D20), not the workspace payload. - Auth: HMAC SHA-256 over the raw body, constant-time compare; 503
when
GITHUB_WEBHOOK_SECRETis unset, 401 on bad signature. Local secret via.dev.vars(the Wrangler convention; gitignored), production viawrangler secret put. GitHub-side webhook registration needs a public URL and therefore rides with the deploy milestone; verified locally with signed payloads (20 API checks + UI render checks).
The drag sensor split is MouseSensor (4px distance) + TouchSensor (250ms
hold, 8px tolerance), replacing the single PointerSensor: on a phone, a
swipe must scroll the board horizontally and a tap must open the card, so
press-and-hold is the only gesture left for dragging — the standard
mobile-kanban convention. Cards get touch-action: manipulation (not
none, which would kill scrolling over cards). Shell padding tightens on
small screens; everything else already reflowed (issue page sidebar stacks
under the content via the existing md: breakpoint). Verified at 390×844
with touch in headless Chromium: no horizontal page overflow on board /
issue / container pages, lane scrolls, taps navigate, dialogs fit; desktop
mouse drag regression-tested after the sensor swap. Production build +
wrangler deploy --dry-run pass; the deploy itself and Cloudflare Access
are owner-credential-gated (dashboard/Zero Trust work, not repo code).
The production /api/workspace returned HTTP 500 while local Miniflare
served it fine. Bisecting by what differs from working paths: write-batches
(the 2-statement issue PATCH) and the 4-statement read-batch behind the
issue timeline both work in production — only the 9-statement read
db.batch in the workspace load failed. The nine reads are independent
and need no transaction, so the fix replaces the db.batch([...]) with
Promise.all([...]) of the same queries — the Cloudflare-recommended
shape for independent reads, which also removes the local-vs-production
runtime difference. The other batches (all writes, plus the small timeline
read-batch — proven working, including 13 PATCHes during the cutover) are
left as-is; speculatively rewriting working production code is the wrong
trade.
Root-cause-of-the-batch was never captured as a stack trace, because the
worker had no error logging at all — an uncaught throw became a bare
Internal Server Error, which is precisely why "look at the logs" turned up
nothing. So the durable half of this fix is an app.onError handler
that console.errors the real exception (visible in wrangler tail) and
returns a generic {error:"internal_error"} body — generic on purpose, so
the Access-bypassed webhook path can't be used to read internals. Any future
500 is now diagnosable from the logs, and the swap-to-Promise.all recipe
is the first thing to try if another read-batch surfaces the same failure.
Per SPEC §7, v1 is "done" when Progress's own backlog moves out of docs/
and into Progress, in production. Executed via scripts/dogfood-cutover.ts
against the live API — not raw SQL — authenticated with the Cloudflare
Access service token (the §8.3 / §11.4 non-interactive-auth pattern,
its first real exercise; same bypass idea as the webhook's HMAC, but for
reads/writes). The script is idempotent (skips existing titles, PATCH-to-
done is a no-op once done). It marked the 14 milestone issues (PROG-1..14)
done, created the Agent Integration arc, and seeded the v1.x backlog
(context bundle, MCP server, work kickoff, the service token itself, this
cutover, PR-driven automation). Production now holds 22 issues across 3
arcs. PROG-15 (an issue the owner had already filed in-app) was left
untouched — real dogfood usage, not seed data. Remaining v1 hookup is
owner-side only: registering the GitHub webhook on connected repos.
GET /api/issues/:key/bundle (SPEC §11.1) returns text/markdown, not
JSON — the bundle is the artifact handed to an agent (or pasted as a
prompt), so the endpoint emits the finished work order rather than fields a
caller must re-render. Errors stay JSON ({error}, 400/404) per the API
contract; only the success body is Markdown. The route is addressed by
issue key, not internal id, and resolves through resolveIssueKeys
(the same alias-aware path the webhook uses): a retired key still resolves
and the bundle always renders the current canonical key. Rendering is
deterministic — values come straight from row data (no Date.now/locale)
and collections are pre-sorted — so the same issue renders byte-for-byte
identically (matters for a copy-as-prompt artifact and for diffing what an
agent was given). Content: issue fields + tags, lineage with descriptions
(product → repo incl. gitUrl → arc — the arc description is the
epic-level "why"), comments, linked PRs/commits, then a stable report-back
preamble (branch/commit/PR mention the key → §5 auto-linking, which works
today; comment/status updates ride the API/MCP surface in PROG-18). The
reads are independent, so they run via Promise.all per [D31]. The "copy as
prompt" button (§11.1) is a thin client follow-on, naturally bundled with
the outbound kickoff work (PROG-19).
The §11.3 MCP surface (src/mcp/server.ts, bun run mcp) is a local stdio
MCP server the owner registers in Claude Code — not a remote MCP endpoint
hosted on the Worker. The deciding constraint is in the issue itself:
"authenticates with the Access service token (§11.4)." A server that
presents the service token is a client of the Access-protected API; a
Worker-hosted endpoint would instead sit behind Access and be
authenticated-to. The client shape also keeps the Worker the single source of
truth for domain logic (the "rigid simplicity" rule) — the MCP server holds no
schema or business rules, only thin wrappers over existing routes plus
key→id/name resolution off one /api/workspace snapshot (the same alias-aware
resolution the Worker does). Validation vocabularies are imported from
src/shared/constants.ts, the dependency-free source of truth, so the tools
can't drift from the API's accepted statuses/priorities/estimates. Tools are
key-addressed (agents speak in keys like PROG-18, not opaque ids) and
mirror the API one-for-one: get_bundle, get_issue, list_issues, create_issue, update_status, comment, move_issue. Auth reads CF_ACCESS_CLIENT_ID/SECRET
(the header names) with a PROD_CF_ACCESS_* fallback so the same .env the
dogfood scripts use just works; PROGRESS_BASE_URL retargets it at local dev.
Transport/SDK: @modelcontextprotocol/sdk over stdio (stdout is the JSON-RPC
channel — the server logs only to stderr). Rejected: a Worker-hosted
streamable-HTTP MCP server — it would duplicate auth handling, add a hosting
surface to the Worker, and contradict the "authenticates with the token"
framing; can be revisited if a browser-side or multi-client MCP need appears.
Read tools verified end-to-end against production over stdio; the write tools
reuse routes already exercised by the dogfood cutover (D32).
The outbound surface (SPEC §11.2) ships as two thin layers over the existing bundle endpoint (D33), no new server code:
- In-app (
src/client/workOn.ts): the issue page's Work on this field and theWpalette command copy either the bundle Markdown ("Copy as prompt" — the §11.1 button) or theprogress work <KEY>CLI line. The bundle is fetched fromGET /api/issues/:key/bundleand prefetched on issue load into a module cache, so the click copies synchronously — honoring "no interaction spinner" (SPEC §8.2) and staying inside the clipboard's user-activation window. - CLI (
bin/progress.ts, exposed asprogress):progress work <KEY>fetches the bundle with the Access service token (the D34 auth pattern), creates/checks outiss/<KEY>, andspawnSyncsclaudewith the bundle as the opening prompt (direct exec, no shell — the Markdown can't be reinterpreted).--printemits the bundle instead;--no-branchskips the checkout.
Two deciding choices: (1) Branch-from-key is default-on, not opt-in —
SPEC calls it "the linchpin" for §5 auto-linking, and doing it at kickoff is
what makes agent commits/PRs flow back with zero ceremony; --no-branch is the
escape hatch. (2) The CLI operates in the current directory and never
resolves a repo from its gitUrl — that keeps Progress free of
machine-specific knowledge of where checkouts live (SPEC §11.2); the user runs
it from the repo they mean. Rejected: a web-UI "launch a cloud session"
button (SPEC §11.2 "Later") — needs headless-Claude infra and a repo-location
map, out of scope for v1.x minimal. The bundle being key-addressed Markdown
(D33) is what lets both layers be this thin. This completes the Agent
Integration arc (D33 bundle → D34 MCP → D35 kickoff).
v1 shipped and was dogfooded, so its roadmap (docs/SPEC.md) is frozen as a
development artifact at docs/archive/SPEC-v1.md and docs/SPEC.md restarts as
the v2 roadmap with fresh section numbers. v1's section numbers are cited by
code comments and earlier decisions, so the archived file is kept unchanged
(its body's relative doc links were repathed ./ → ../ for the new depth, the
only edit) and those SPEC §X citations resolve there.
v2 broadens Progress from a product-dev tracker to any area of responsibility (incl. personal/household). Four scoping calls, settled this session:
- Keep the nouns. A household area is modeled as a Product with Arcs; Repo stays optional/dev-only and repo-less products become first-class. No vocabulary change. Rejected: a domain-neutral top-level noun or a parallel personal hierarchy — both cost the "rigid simplicity"/"owner's nouns" hard requirements to save one small mental stretch ("Product" = life-area).
- Due dates are one-off, date-only, timezone-safe. An optional issue field
holding a calendar day (
YYYY-MM-DD), the same date everywhere — not an instant, unlikecreatedAt/updatedAt. Rejected for this phase: recurring due dates (the likely next step — chores repeat — so the model/Agenda are built not to preclude it), date+time, start dates, reminders. - A dedicated Agenda view. Dated issues grouped Overdue / Today / This week / Later, sorted by due date, each row with a visual priority indicator; undated and completed issues excluded; filterable by product/arc/tag (URL params, like the board). Rejected: a sort-mode bolted onto the existing kanban, or a flat ungrouped list — the grouped view is the one that answers "what's due" at a glance.
- Structure creation is surfaces, not new endpoints. Inline "+ New …" in the create-issue pickers plus dashboard entry points (header "New" + a Structure overview route) reuse the v1 container write paths (D26); nothing new server-side. Folds in the previously-deferred "add arc from the New Issue modal".
Full plan: docs/SPEC.md (v2). Build sequence is SPEC §11; the one schema change
is a nullable due_date on issues.
The v2 roadmap (docs/SPEC.md) shipped end-to-end and was deployed to
production (version 18db5f52, migration 0003_breezy_spot.sql). Four
build-time calls settle the SPEC §9 open questions.
The new issues.due_date column is nullable TEXT holding a canonical
YYYY-MM-DD calendar day, not a normalized integer and not an instant
(contrast createdAt/updatedAt, which are unix-epoch timestamps). This is the
timezone-safety requirement of SPEC §5 made concrete: a due date is the same
wall-calendar day everywhere, so it must not carry a time or zone. Text keeps it
human-readable in the DB and in the workspace payload, sorts correctly
lexicographically (so the Agenda sorts by string compare), and round-trips
through JSON unchanged. The API validates the canonical form and rejects
impossible dates (e.g. 2026-13-40) by re-serializing through UTC midnight.
Rejected: an integer (days-since-epoch or packed YMD) — saves nothing and
loses legibility. Closes SPEC §9 Q5.
The Agenda is its own route (/agenda) and its own header nav entry, alongside
the board — not a tab or sort-mode bolted onto the kanban. The board answers
"what's the state of the work"; the Agenda answers "what's due," and that
question deserves a first-class home. "This week" is a rolling 7 days from
the local today (buckets: Overdue = before today, Today, This week = next 6
days, Later) — simpler to reason about than "through end of calendar week," and
it matches how a due list is actually read. Buckets compute from the owner's
local day since due dates are calendar days. Closes SPEC §9 Q1 and Q2.
One reusable component (PriorityIndicator) renders the fixed
urgent/high/medium/low/none scale as a small filled dot on the global palette —
urgent #ED6245 (red), high #F08B23 (orange), medium #F2C42E (yellow), low
#546EB4 (slate); none is a hollow gray ring so "no priority" reads as unset
rather than a fifth color. One mapping in labels.ts (PRIORITY_COLORS), no
configuration. Defined once for the Agenda; the board and lists may adopt it.
Rejected: bars or flags — a dot is the most compact at list density. Closes
SPEC §9 Q3.
Curating structure gets a dedicated route showing the Initiative → Product → (Repo · Arc) tree with an inline "+ add" on each node — kept off the home board so the board stays uncluttered. Plus a persistent New menu in the app header (Issue · Initiative · Product · Repo · Arc) and inline "+ New product/arc" in the create-issue dialog (which folds in the long-deferred "add arc from the New Issue modal"). All of it reuses the v1 optimistic container write paths (D26) — no new write endpoints; v2's structure work is surfaces only. Closes SPEC §9 Q4.
Adopted the finished "Progress — Adobe & Moss" brand (delivered in
brand-assets/): a muted, papery, high-contrast look — deep ink (#2c241b) on
cream paper/canvas, Spectral for headings/body and IBM Plex Mono for
labels/meta/keys, with two semantic accents — Salmon Adobe (#bb6f50) for
primary actions / active "now" states (CTAs, links, active nav, open PRs) and
Olive Moss (#79864c) for completed/grounded states (done, merged PRs).
Tokens live in brand-assets/tokens.css (source of truth) and are mirrored
into Tailwind v4's @theme in src/client/styles.css rather than loaded as a
separate stylesheet, so each token is a utility (bg-paper, text-ink,
bg-adobe, border-line, font-mono, brand --radius-*). All ~250 hard-coded
stone/sky/red/emerald/purple utilities were refactored to these tokens
(one-shot pass, scripts/retheme.mjs). Brand icons + manifest.webmanifest
ship from public/brand-assets/. Kept on-system: errors/overdue use a derived
--danger (papery tomato), not a stock red; the priority-dot scale (D39) stays
on the global mermaid palette — it's a data encoding, not chrome. Rejected:
linking tokens.css directly (the handoff's non-Tailwind path) — duplicates the
palette outside Tailwind's utility system.
Supersedes D12. The app now owns authentication and identity instead of
delegating to the Cloudflare Access edge. Motivation (PROG-34): Access was only
a gate — past it the Worker had zero identity awareness and every write was a
hardcoded usr_owner, so content was effectively anonymous. The Worker now runs
the Google OAuth Authorization Code flow itself (src/worker/auth.ts,
/api/auth/login · /callback · /logout), mints a stateless signed session
cookie (HS256 via hono/jwt; no sessions table — this is still the only writer
of its own data), and an /api/* middleware resolves identity per request and
attributes every write to it. Decisions within: (1) owner-only allowlist —
sign-in is gated by ALLOWED_EMAILS (currently just the owner), so an open
Google account can't read the workspace; (2) bearer token for automation —
the MCP server, progress work CLI, and dogfood scripts drop the Access
service-token headers for Authorization: Bearer <PROGRESS_API_TOKEN> (→ owner),
keeping a non-interactive path without Access; (3) unconfigured = dev owner —
when the OAuth secrets are absent the middleware falls back to usr_owner, so
bun run dev and tests need no Google setup; (4) id_token validated by claims,
not signature — it's received directly from Google's token endpoint over TLS
(the Google-sanctioned shortcut), so we check iss/aud/exp but skip JWKS/RS256,
keeping the module dependency-free. The webhook keeps its own HMAC and bypasses
the new middleware. Migration 0004_owner_email.sql repoints the seeded
usr_owner to the owner email so sign-in resolves to the existing row,
preserving all historical attribution. Rejected: keeping Access with Google as
its IdP (doesn't move identity into the app — the stated goal); a D1 sessions
table (statelessness suffices); a users.google_sub column (email matching is
enough for owner-only; can add later as a stable anchor). Cutover steps + Access
teardown: SETUP §6.
Issues now have an explicit vertical order on the board, not just a status
column — so the owner can rank what to work on next, putting one card ahead of
another. The order is stored as a per-issue rank: a string
fractional-index key (src/shared/rank.ts) that sorts lexicographically, so a
card dropped between two others gets a key between their keys and the move is a
single-row write — no renumbering of neighbors, which keeps reordering
optimistic and instant (Hard requirement #1). Ranks are one global order; sorting
only ever compares cards within a column, so global position doubles as
in-column position. New issues are appended after the current last rank (bottom
of their column); migration 0005_issue_rank backfills existing rows in the old
board order (by product, then issue number). The board upgrades from
@dnd-kit/core draggable/droppable to @dnd-kit/sortable for within- and
cross-column positional drops; a cross-column drop sends status + rank in one
PATCH. Decisions within: (1) fractional index over an integer position —
an integer scheme needs to renumber a whole column per drop (N writes) or leave
gaps that still eventually collide; fractional keys are O(1) writes and never
need rebalancing at single-user scale. (2) home-rolled, dependency-free helper
over the fractional-indexing npm package — keeps src/shared dep-free (like
constants.ts) and lets the migration backfill in pure SQL with compatible
fixed-width decimal keys; covered by bun test (100k-insertion torture test).
(3) base-62, ASCII-ordered alphabet so a byte-wise compare (SQLite's default
BINARY collation, and the client's <) equals digit order; keys are kept
canonical (never end in "0") so any gap stays subdividable. (4) client
computes the key, server only validates it's well-formed — mirrors the existing
optimistic-mutation split. Rejected: per-column integer positions (write
amplification); localeCompare for the client sort (case-folding would mis-order
letter-bearing keys — must be a binary compare).