pinata

Decisions

Source: docs/decisions/decisions.json · Revision: ee94329926a9cd1739e29a47a9cf0cf27235f84f

148 decisions, in record order. Provenance labels distinguish what the human directed, what the agent proposed and the human approved, what the agent decided alone, and what was consciously deferred.

D001 — Name the project "pinata"

2026-09-04 · setup · accepted · Human directed

Problem

The project needed a name before a repository could be created, and the name would signal the product concept.

Decision

Name it `pinata`, read as a portmanteau of **pin** + **anno**tation + at **ya**.

Rationale

Specified by the human with the gloss already attached. The pun encodes the product thesis — annotations that get pinned somewhere and then come back at you — which is a useful constraint to design against.

Consequences

  • The name sets an expectation that the MVP involves pinning annotations to something and resurfacing them.
  • `pinata` collides with the well-known IPFS pinning service Pinata; worth revisiting for anything public-facing.

Provenance

Human: “createa new directory here init a github repo and call it pinata (a portmanteau for pin attotation at ya) this is a public github repo in the lucasdickey account”

D002 — Public GitHub repository under the lucasdickey account

2026-09-04 · setup · accepted · Human directed

Problem

Repository visibility and ownership had to be chosen at creation time, and visibility is awkward to reason about after secrets or history exist.

Decision

Create `github.com/lucasdickey/pinata` as a public repository from the outset.

Alternatives considered

  • Private repository, opened up later — Not requested, and it would make the build history harder for a reviewer to inspect.

Rationale

Specified by the human. Public from the first commit also means the full commit history is reviewable evidence that the work was built new.

Consequences

  • No credentials, tokens, or client data may ever enter this repository, including in the screenshots attached to decision records.
  • The commit graph is part of the deliverable, so commits should be legible rather than squashed into one.

Provenance

Human: “this is a public github repo in the lucasdickey account”

Artifacts

D003 — Default branch left as master pending a call

2026-09-04 · setup · superseded · Raised and deferred

Problem

The local git config defaults to `master`, so the pushed repository has `master` as its default branch. Renaming is trivial now and annoying once branches and CI exist.

Decision

Left as `master` for the moment. Flagged to the human rather than renamed silently.

Alternatives considered

  • Rename to `main` unilaterally — Cosmetic but visible on a repository that will be reviewed by others; not the agent's call to make without asking.

Rationale

A one-word question is cheaper than an unrequested change to something the human will see in the GitHub UI.

Consequences

  • If this is left as `master`, any CI configuration and branch protection must reference `master` consistently.

Provenance

Agent proposed: “One note: your local git defaults to `master`, so that's the default branch on GitHub. Say the word if you want it renamed to `main`.”

Superseded by D009.

D004 — Treat the decision trail as a shipped deliverable

2026-09-04 · setup · accepted · Human directed

Problem

Explaining the process and the decisions is part of the work. Reconstructing that narrative at the end of a build produces a sanitized story that omits the reversals, which are the interesting part.

Decision

Document decisions continuously as the build proceeds, in three forms: agent rules encoded in `AGENTS.md`, a plain-text Markdown log for reading afterwards, and an HTML dashboard that opens in a browser and can carry screenshots. Each decision is explicitly tagged with whether the human requested it or the agent proposed it and the human approved.

Alternatives considered

  • Write a retrospective at the end of the build — Hindsight flattens the trail. Dead ends get quietly dropped and every choice looks inevitable.
  • Rely on the git log and the Factory session transcript — Both record what happened but not why, and neither distinguishes a human instruction from an agent suggestion that was rubber-stamped.

Rationale

Requested by the human. The provenance split is the load-bearing part: it makes the division of labor between human and agent auditable instead of asserted.

Consequences

  • Documentation upkeep consumes part of the 4-hour timebox and must be counted honestly in the session log.
  • Every material choice from here forward carries a small logging tax.
  • The agent must ask for approval more explicitly than usual, because approvals are now evidence.

Provenance

Human: “We will be performing the exercise request within, but I want us to set our project work up to reflect the ultimate outcome being requested. Let's document our work as I go. Let's identify decisions that we made that were critical as we go. In particular, let's identify those that I requested versus those that I approved that you asked for approval on. Set up our agents.md file or our Claude.md file to reflect that this is what we want and that we want something like a plain text Markdown file to review afterwards. I also like the idea of us constructing an HTML file that loads in the browser and shows decisions as we progress, and this can include helpful screenshots as well.”

Artifacts

  • screenshot screenshots/D004-dashboard.png — The dashboard on first render: provenance counters across the top, origin and phase filters, two records expanded
  • file AGENTS.md — The protocol the agent is now bound by

D005 — One JSON source of truth, both human-readable artifacts generated

2026-09-04 · setup · accepted · Agent decided alone

Problem

The human asked for two views of the same decision trail: a Markdown file and an HTML dashboard. Maintaining both by hand guarantees they drift, and drift in the artifact that is supposed to prove rigor is worse than having one view.

Decision

Hand-edit `docs/decisions/decisions.json` only. Generate `docs/DECISIONS.md` and `docs/dashboard/decisions-data.js` from it with `node scripts/build-docs.mjs`. Ship the dashboard as a static page that reads a generated JS data island so it opens over `file://` with no server and no dependencies.

Alternatives considered

  • Hand-write DECISIONS.md and have the dashboard parse the Markdown — Markdown parsing in the browser needs a dependency, and freeform prose is a fragile schema for the provenance tagging that is the whole point.
  • Have the dashboard `fetch()` decisions.json directly — Browsers block `fetch` against `file://` origins, so the page would require a local web server. A reviewer should be able to double-click the file.
  • Static site generator — Dependency install, build step, and lock file, all to render roughly twenty records inside a four-hour timebox.

Rationale

Chosen unilaterally because it is an implementation detail of a deliverable the human had already specified, it is fully reversible, and it adds no dependencies. Labeling it `agent-autonomous` rather than approved is the honest call: the human asked for the two artifacts, not for this mechanism.

Consequences

  • `docs/DECISIONS.md` and `docs/dashboard/decisions-data.js` must never be hand-edited.
  • The generator has to be run before every commit or the repository ships stale docs.
  • A malformed `decisions.json` breaks both views at once, so the generator validates required fields and fails loudly.

Artifacts

  • file scripts/build-docs.mjs — The generator

D006 — Build the scaffolding before fixing the product concept

2026-09-04 · concept · accepted · Agent decided alone

Problem

The human asked to get the holistic structure in place and then start building, but the product concept was not yet stated beyond what the name implies. Guessing the concept risked building the wrong thing; waiting risked burning the turn on nothing.

Decision

Build the documentation apparatus first, deliberately concept-agnostic, and surface the product-direction question for the human to answer before any application code is written.

Alternatives considered

  • Infer the concept from the name and start building — "Pinned annotations" admits several very different products. Building the wrong one costs more than one question does, against a 4-hour budget.
  • Ask first and build nothing this turn — The scaffolding was already fully specified and does not depend on the concept, so it was free to do in parallel.

Rationale

The scaffolding has no coupling to the product, so doing it first is pure parallelism. The concept question is the one place where a wrong guess is expensive, so it is the one place worth blocking on.

Consequences

  • `src/` stays empty until the concept is settled.
  • The first product decision record will be `user-directed` or `agent-proposed-user-approved`, never `agent-autonomous`.

Provenance

Human: “Once we have our holistic structure placed together, let's go ahead and get started.”

D007 — One command is the quality gate: npm run validate

2026-09-04 · validate · accepted · Human directed

Problem

Factory refused to start a Mission here, warning that the folder was not a git repository and that Missions need strong validation capability or they will incorrectly infer how to QA the application. Two separate faults sat behind one warning. First, `/missions` was being run from `~/Documents/code`, which is a plain directory, while the actual repository is the `pinata` subdirectory. Second, and more seriously, the project genuinely had no way to tell a good change from a bad one: no tests, no lint, no build, nothing an autonomous agent could run to check its own work.

Decision

Run Missions from inside `pinata/`, and give the repository a real validation contract before any Mission touches it. `npm run validate` is the single gate, chaining `lint` then `docs:check` then `test`. It is documented in `AGENTS.md` section 3 as the authoritative answer to "how do I know a change is good?", and CI runs the identical command so local green and CI green mean the same thing.

Alternatives considered

  • Proceed and accept the warning's risk — The warning is accurate. An agent with no validation signal optimizes for looking finished, and the failure surfaces later as confidently broken output.
  • Add a test framework such as Vitest or Jest — Node 20 ships `node:test`, which covers this need exactly. A framework would break the zero-dependency rule that keeps the docs artifacts working with no install step.
  • Wait until product code exists before adding tests — The documentation apparatus is already the largest thing in the repo and is itself a deliverable. It needed covering regardless, and having the gate in place first means product code inherits it.
  • Init a git repo in the parent directory to silence the warning — That directory holds dozens of unrelated projects. It would hand a Mission a working tree of other people's work to reason about.

Rationale

Requested by the human as an explicit precondition to running a Mission. The design principle chosen inside that request: validation has to be one command, dependency-free, and deterministic, because a gate that is slow, flaky, or awkward to run does not get run. Determinism specifically forced generated artifacts to embed a source hash rather than a wall-clock timestamp, since otherwise every build differs from the last and a staleness check can never pass.

Consequences

  • `npm run validate` must pass before any work is called finished, by a human or an agent.
  • Generated output must stay a pure function of `decisions.json`; no timestamps, no environment-dependent content.
  • Product code landing in `src/` adds its tests to the same `test/` directory rather than introducing a second runner.
  • The test suite is now load-bearing documentation: it encodes the provenance rules from `AGENTS.md` 2.3 as executable assertions, so the protocol cannot quietly rot.
  • Missions must be launched from `pinata/`, not from the parent directory.

Provenance

Human: “let's do this first before we run /missions: You are about to run a Mission on a folder that is not a git repository, so we cannot evaluate its agent readiness. Missions can only run effectively when there are strong validation capabilities built into the project. By proceeding, you are claiming that these capabilities exist or taking the risk that the Mission will incorrectly infer how to QA your application.”

Artifacts

  • file AGENTS.md — Section 3 states the validation contract a Mission should read
  • file .github/workflows/validate.yml — CI runs the same single command

D008 — Test the provenance rules, not just the code that renders them

2026-09-04 · validate · accepted · Agent decided alone

Problem

The decision log's value rests entirely on the provenance tags being trustworthy. Nothing stopped a future agent, including me, from tagging a choice `user-directed` with no quote behind it, or from quietly relabelling something it had decided alone as something the human approved. A convention written in prose in `AGENTS.md` is a convention that erodes.

Decision

Encode the section 2.3 rules as executable assertions. The validator rejects `user-directed` without `transcript.request`, rejects `agent-proposed-user-approved` unless both the proposal and the approval are quoted, holds `user-deferred` at `status: pending`, and refuses dangling or self-referential supersession pointers. `npm run validate` therefore fails on a dishonest record, not merely a malformed one. The suite also checks the real repository: gapless ids, screenshots that actually exist on disk, generated files that are not stale, and a dashboard that reads no field the generator stopped emitting.

Alternatives considered

  • Trust the convention as documented prose — Unenforced conventions decay, and this one decays in the direction of flattering the agent.
  • Only test the pure render functions — That catches template bugs while missing the failure that matters: a committed record with no evidence behind its claim.

Rationale

Decided without asking because it is a strictly stronger version of a protocol the human had already specified, and it constrains the agent rather than the human. Worth its own record because it earned its keep immediately: the suite caught a real bug where slugs collapsed runs of spaces into one hyphen while GitHub emits one per space, which would have silently broken every index link in `DECISIONS.md` for any title containing an em dash or an ampersand.

Consequences

  • Adding a decision record now requires its evidence, or the build fails.
  • Screenshots must be committed alongside the record that references them.
  • `githubSlug` is pinned by tests with an independent second implementation as the oracle, so the two cannot drift into agreeing on the wrong answer.

Artifacts

  • file test/decisions.test.mjs — The provenance rules as executable assertions
  • file test/artifacts.test.mjs — Integrity checks against the real repository contents

D009 — Rename the default branch to main

2026-09-04 · setup · accepted · Agent proposed, human approved

Problem

`D003` deferred this. The local git default produced a `master` branch, which stayed the default on a public repository that will be reviewed. Leaving it deferred meant CI had to trigger on two branch names and the answer would get more expensive the longer branches and history accumulated.

Decision

Rename to `main`. Done as a pointer move at the same commit — `git branch -m`, push `main`, set the GitHub default, delete the remote `master` — so no history was rewritten and the SHA is unchanged. CI now triggers on `main` only.

Alternatives considered

  • Keep `master` — Rejected by the human. It also meant carrying a two-name CI trigger indefinitely to cover a question nobody had answered.
  • Defer again until the product concept lands — The cost of renaming only grows. Doing it while the repo is two commits deep and has no open branches is the cheapest this will ever be.

Rationale

The agent raised it, twice, and the human chose. Recorded as `agent-proposed-user-approved` rather than `user-directed` because the human never asked for a rename; they answered a question the agent put to them. The distinction is exactly what the origin field exists to preserve.

Consequences

  • Anyone with an existing clone needs `git branch -m master main` and a new upstream, or a fresh clone.
  • `master` no longer exists on the remote; references to it are dead.
  • The CI trigger list is now a single branch, so a future rename would need the workflow updated too.

Provenance

Agent proposed: “Rename the GitHub default branch from master to main? Still unresolved (D003); CI currently triggers on both.”

Human approved: “Rename to main”

Artifacts

  • file .github/workflows/validate.yml — Trigger list reduced to main

Supersedes D003.

D010 — Commit straight to main, with CI as the only gate

2026-09-04 · setup · accepted · Agent proposed, human approved

Problem

The first chunk of work went through a branch and a pull request, which produced a reviewable narrative but cost a round trip on a solo project inside a 4-hour timebox. The question was whether to keep paying that.

Decision

Commit directly to `main` from here on. No feature branches, no pull requests. `npm run validate` before every commit stays mandatory, and CI runs the same command on every push, so the gate is unchanged — only the ceremony is gone.

Alternatives considered

  • Keep a branch and PR per chunk — Rejected by the human. On a single-author project with nobody to review, the PR adds latency without adding a reviewer.
  • Run a Mission from inside pinata/ — Not chosen now. It remains available, and `D007` put the validation contract in place precisely so it would be safe when it is.

Rationale

The agent offered the options and the human picked. Worth recording because it removes a safety net: with no PR, the pre-commit `validate` run and CI are the only things standing between a bad change and the default branch. That trade is acceptable only because the gate is fast, deterministic, and dependency-free.

Consequences

  • `main` is no longer protected by review, so a broken commit lands on the default branch before CI reports.
  • `npm run validate` must pass locally before every commit, not merely before every merge.
  • PR #1 remains the one place where the reasoning is narrated in review form; later reasoning lives only in the decision log and the session log, which raises the stakes on keeping both current.

Provenance

Agent proposed: “Now that CI is green, how should we work from here? Feature branch + PR per chunk, like this one / Commit straight to the default branch / Run a Mission from inside pinata/”

Human approved: “Commit straight to the default branch”

D011 — Commit and push early and often, especially at decision points

2026-09-08 · setup · accepted · Human directed

Problem

Work from Session 01 and afterward sat uncommitted in the working tree: a .gitignore update, a vendored agent skill, and a brand asset. The commit graph is itself part of the record (D002), so progress that exists only locally is invisible to anyone reading it and one accident away from being lost.

Decision

Commit and push to the remote frequently rather than batching — at minimum whenever a decision is recorded or implemented. Reversal is cheap with git, so the bias is toward publishing small commits early.

Alternatives considered

  • Commit in large batches at natural milestones — Batching hides the build narrative the decision log exists to surface, and leaves work sitting locally where it can be lost.
  • Commit locally and push at milestones — The remote repository is the review surface; unpushed work may as well not exist for the reviewer.

Rationale

Directed by the human, with the reasoning supplied: reversing a commit is easy, so there is no upside to sitting on uncommitted work. This also reinforces D002's consequence that the commit history is reviewable evidence the work was built new. Complements D010 (no PR ceremony) with the push-frequency half of the same hygiene rule. Originally drafted as D009 in a session whose tree pre-dated the published D009/D010; renumbered on merge, which is itself recorded in Session 03.

Consequences

  • The existing regenerate-docs-before-committing rule now applies at a higher frequency.
  • Every push is immediately public, so staged content must be checked for secrets and client data each time, not just at milestones.
  • Commits stay small and their bodies name the decision IDs they implement.

Provenance

Human: “please be sure to commit and push to remote as we make decisions, so we don't sit with an empty git repository. i'd rather commit and push often, given the ease of reversing with git source control. and especially wiht key decisions we've decided on.”

Artifacts

  • file AGENTS.md — Section 4 commit-hygiene rule updated to require frequent pushes

D012 — Define the product: directional feedback on friends' public websites

2026-09-08 · concept · accepted · Human directed

Problem

D006 left the product concept deliberately open, blocking all application code. The human stated the problem in their own words at the start of Mission planning (2026-09-06); recorded here on 2026-09-08 along with the rest of the planning decisions.

Decision

Pinata is a lightweight, Figma-like web workspace for giving directional feedback on friends' public SaaS, product, pricing, and documentation pages. Static full-page captures, pinned annotations with comments, and a simple founder reply loop. Explicitly out of scope: deterministic copy/style edits (the founder owns the edits), interaction or animation capture, and runtime AI — feedback stays human-authored and directional.

Alternatives considered

  • A deterministic editing tool that applies copy/style changes directly — Explicitly rejected by the human: "We're not making an IDE." Directional suggestions, not pedantic instructions.
  • Runtime AI to generate or transform feedback — Rejected unless a compelling reason emerges; token consumption needs justification, and the product thesis is human-authored feedback.

Rationale

Stated directly by the human in the mission brief, resolving D006 exactly as D006 predicted: the first product decision is user-directed. The concept matches the name thesis from D001 — annotations pinned to a page, coming back at the founder.

Consequences

  • `src/` can now be built against a fixed concept.
  • No AI provider keys are needed at runtime; the constraint can only be revisited with explicit justification.
  • Feature requests for deterministic edits or interaction capture are out of scope by definition.
  • The decision, session-log, and dashboard protocol from D004/D005 remains the record-keeping contract for everything that follows.

Provenance

Human: “Solution: Figma-like, but much lighter weight. Easy to input/create feedback, from any computer I might have access to, targeting any friends website. [...] I do not wish to deterministicaly make copy edits, change colors/styles, etc. That is responsibility of the founder friend, I just ant to give directional feedback for things to consider or try, rather than pedantic/deterministic instructions. We're not making an IDE. [...] I'd prefer that we limit the use of AI/token consumption for this software as it operates unless there is a REALLY good reason to consider otherwise”

Artifacts

D013 — Chickpea is the canonical real-world test target

2026-09-08 · concept · accepted · Human directed

Problem

Evals against synthetic fixtures would not surface the failure modes that matter — lazy-loaded content, dense pricing tables, real CSS. And the product has a real first user with a real deadline behind it.

Decision

Use `https://chickpea.co/` plus the explicit URL array `/pricing`, `/about`, `/privacy` as the primary validation target throughout the build. The first real project is feedback for Chickpea's founder. Captures are internal test/demo material with source attribution, not reusable marketing assets.

Alternatives considered

  • A local fixture site for capture testing — A fixture cannot reproduce lazy loading, cookie banners, or dense real-world layout; the capture pipeline must handle a real production site from day one.

Rationale

Directed by the human, who needs to send feedback to Chickpea's founder soon. This makes the eval target and the first real use case the same thing, which is the strongest kind of eval.

Consequences

  • The validation contract asserts against live Chickpea pages, so tests inherit that site's uptime and content drift as a managed risk.
  • URL arrays are explicit: no crawling or link discovery.
  • Desktop and mobile captures of the same URLs are both required, so geometry alignment between viewports is a first-class concern.

Provenance

Human: “i want to use this site for our core test: http://chickpea.co/ - as it's the one we're using for real-world inspiration as I need to provide feedback to Pejman ("founder") ASAP”

D014 — Next.js + React + TypeScript on Vercel is the application stack

2026-09-08 · design · accepted · Agent proposed, human approved

Problem

The product needed a stack that ships an MVP fast, integrates natively with the chosen storage services, and keeps authorization server-side without standing up a separate API.

Decision

Build the app with Next.js + React + TypeScript, deployed to Vercel. Server routes and actions authenticate actors, enforce role permissions, validate URLs and payloads, and mediate all storage access. Local development runs on `127.0.0.1:3100`; port 3000 and all pre-existing local processes are off-limits (approved separately).

Rationale

Proposed by the agent as part of the binding architecture and approved wholesale by the human. Next.js on Vercel collapses hosting, server-side authz, and the Blob/Turso integrations into one deploy target, which matters against a 4-hour-flavored budget even though the mission is allowed to run longer.

Consequences

  • The password prompt is client-visible but credential checks are always server-side; no secret ships in browser code.
  • All Blob and Turso access passes through authorized application routes — the browser never holds storage credentials.
  • Port 3100 is reserved for Pinata; start/stop/health checks hardcode it.
  • GitHub Actions runs the same validation command as local development.

Provenance

Agent proposed: “My proposed binding architecture is: Next.js + TypeScript on Vercel; MIT React Flow for the canvas; Browserless Function API for aligned screenshot and DOM-manifest capture; Private Vercel Blob for images; Turso/libSQL + Drizzle for projects, captures, feedback, immutable replies, and capability rotation; App password prompt checked server-side against environment secrets; Local app on port 3100; port 3000 and all existing processes remain off-limits”

Human approved: “Approve as proposed”

D015 — MIT React Flow is the canvas foundation

2026-09-08 · design · accepted · Agent proposed, human approved

Problem

The annotation canvas needs pan/zoom, custom nodes, and precise coordinate control. The obvious premium option charges license fees; the free options differ widely in fit.

Decision

Use MIT-licensed React Flow. Each page/device capture gets its own coordinate plane in screenshot-natural pixels, with pages grouped in a project sidebar rather than placed on one shared infinite canvas. Pins, rectangles, and circles are custom nodes parented to the screenshot; arrows are React Flow edges with draggable endpoint nodes.

Alternatives considered

  • tldraw with a trial/license key — License cost against an explicit keep-costs-low goal from the human.
  • MIT Excalidraw embed — Sketch-oriented; weaker fit for numbered pins, metadata attachment, and pixel-exact coordinate persistence.

Rationale

The human picked the option and supplied the reasoning: cost. Screenshot-natural coordinates mean browser size and canvas zoom never move a target, which is what makes deep-zoom feedback trustworthy.

Consequences

  • Annotation geometry is stored immutably in screenshot-natural pixel coordinates; adapters translate to screen space, never the reverse.
  • Desktop and mobile captures are independent coordinate planes; annotations never migrate between them.
  • No canvas license fees or key management.

Provenance

Agent proposed: “Which canvas foundation should Pinata use for the live app?”

Human approved: “let's do MIT React flow, as one of my other goals was to keep costs low - rather than paying for tldraw”

D016 — Browserless captures screenshots and DOM manifests in one session

2026-09-08 · design · accepted · Agent proposed, human approved

Problem

The screenshot and the DOM metadata manifest must come from the same layout state or pins cannot be attached to elements reliably. Self-hosting headless Chromium is operational burden the MVP does not need.

Decision

Use the Browserless Function API: one fresh browser session per URL and viewport, desktop and mobile at CSS-pixel scale. Each session navigates to a validated public HTTPS URL, incrementally scrolls lazy content, freezes visual animation, extracts a bounded and sanitized element manifest, and captures the final image from that same state. Maximum two concurrent captures.

Alternatives considered

  • A screenshot-only API such as ScreenshotOne — Simple only if page URLs are already known and no DOM manifest is needed; it cannot extract aligned metadata in the same session.
  • Self-hosted Playwright/Chromium — The human asked for a hosted provider with good APIs/CLIs rather than infrastructure to run.

Rationale

Proposed by the agent and ratified through the umbrella architecture approval (there was no standalone Browserless question — recorded honestly). The manifest is deliberately bounded: tag, role, short visible text, accessible name, structural path, document-space rectangle. It excludes HTML source, cookies, storage, form values, hidden content, and cross-origin iframe internals.

Consequences

  • Only public absolute HTTPS targets are captured; redirects, private addresses, metadata endpoints, unsupported schemes, excessive height, and timeouts are rejected.
  • DOM metadata is descriptive context attached to an immutable capture, never an executable selector or editing mechanism.
  • Concurrency is capped at two to stay inside the Browserless free-tier limit.

Provenance

Agent proposed: “Browserless rather than a screenshot-only API if "core top-level pages" means discovering navigation links. Browserless can inspect the page and capture both viewports in one browser platform. ScreenshotOne is simpler only if page URLs are already known.”

Human approved: “Approve as proposed”

D017 — Private Vercel Blob for images, Turso/libSQL + Drizzle for metadata

2026-09-08 · design · accepted · Agent proposed, human approved

Problem

Screenshots are large binary assets and project data is relational; both need managed, Vercel-friendly homes that stay within free tiers and require no server administration.

Decision

Store screenshots in private Vercel Blob and everything else — projects, pages, captures, annotation geometry, DOM metadata references, threads, actor roles, share-token digests, revisions — in Turso/libSQL via Drizzle. All reads and writes pass through authorized application routes.

Alternatives considered

  • Neon (Postgres) instead of Turso — Offered as an explicit option in the approval prompt; the human approved the stack as proposed. Turso is lighter and its CLI verified cleanly in readiness checks.

Rationale

Proposed by the agent and approved wholesale. Both services completed real write/read/delete verification during mission readiness checks before this was recorded, so the choice is evidence-backed rather than brochure-backed.

Consequences

  • Share-token digests are stored, never the tokens themselves.
  • Database triggers reject UPDATE/DELETE on founder replies, enforcing immutability below the application layer.
  • Mutations use prepared queries, origin/CSRF checks, and durable rate limits.

Provenance

Agent proposed: “Private Vercel Blob plus Turso/libSQL for screenshot assets and project/comment metadata. This is lighter than Postgres, integrates with Vercel, and has a strong CLI. An unguessable edit URL acts as a bearer capability; display names are labels, not verified identities.”

Human approved: “Approve as proposed”

D018 — Editor password prompt, persistent founder capability links, append-only threads

2026-09-08 · design · accepted · Agent proposed, human approved

Problem

The product needs exactly two roles — Lucas as editor, recipients as founders — without a signup system. Access control has to be real (server-enforced) while staying proportionate to a friends-and-founders tool.

Decision

Lucas authenticates through an in-app password prompt whose credential is checked server-side against an environment secret. Founders enter through high-entropy, unguessable project links that act as bearer capabilities and persist until Lucas rotates or revokes them. Threads are chronological and append-only: founders reply as `founder`, Lucas appends follow-ups, and founder replies are immutable for everyone — no update/delete endpoint exists for them.

Alternatives considered

  • Browser HTTP Basic prompt or Vercel deployment protection — Basic auth is hostile to demo and to founders; deployment protection would lock founders out entirely.
  • Founder links that expire after 30 days or are one-per-recipient — The human chose persistence-until-rotation; expiry adds a renewal flow the MVP does not need.

Rationale

Two separate approval questions, answered directly by the human. Immutability of founder replies is the trust mechanism: feedback history cannot be rewritten by either side, which makes the tool safe to point at someone's product.

Consequences

  • Founder view is read/reply only: founders cannot create, move, edit, or delete annotations.
  • Link rotation and revocation are first-class operations, not admin chores.
  • Server-enforced least privilege for both roles; display names are labels, not identities.

Provenance

Agent proposed: “When you said "env secrets basic auth," which Lucas login experience do you want? [...] How should shared founder links behave over time?”

Human approved: “we will store password as an env credential and it'll be enforced on the front end with a prompt [...] Persist until Lucas rotates/revokes”

D019 — Standardize on Node 24 across app, CI, and Vercel

2026-09-08 · setup · accepted · Agent proposed, human approved

Problem

The scaffold was built on Node 20, Vercel deploys run Node 24, and the local machine drifted to Node 25 — under which `node --test test/` already broke once (Session 02). Three runtimes is a standing source of works-here failures.

Decision

Standardize the application, CI, and repository documentation on Node 24, matching Vercel.

Alternatives considered

  • Node 20, matching the scaffold — Would require downgrading Vercel and fighting the platform default.

Rationale

Proposed by the agent with an explicit recommendation; the human's answer was a delegation ("i defer to you") rather than a picked option, and it is recorded as approval-by-deferral for honesty. The recommendation stood because it removes a drift axis that had already produced a real failure.

Consequences

  • README and CI pin Node 24 once the product stack lands.
  • The Node 25 `node --test` directory-form breakage (Session 02) stays fixed via the explicit glob, which behaves identically on Node 24.

Provenance

Agent proposed: “Which Node runtime should the application, CI, and Vercel standardize on? [...] I recommend standardizing the new app, CI, and repository on Node 24 rather than downgrading Vercel. This supersedes the scaffold's pre-product runtime choice.”

Human approved: “i defer to you”

D020 — Product-stack transition: Vitest and Playwright join the single validate gate

2026-09-08 · validate · accepted · Agent proposed, human approved

Problem

The zero-dependency `node:test` gate chosen in D007 cannot cover a Next.js application — no component tests, no browser flows, no typecheck. But multiplying entry points would break the single-command contract that makes the gate runnable.

Decision

`npm run validate` remains the single gate and grows to aggregate: ESLint plus repository integrity checks, TypeScript typecheck, Vitest suites, the deterministic docs check, the Next.js production build, and Playwright Chromium end-to-end tests. GitHub Actions runs the identical Node 24 command. The docs tooling itself stays zero-dependency, and the existing `node:test` suite keeps running until its assertions are migrated.

Alternatives considered

  • Keep node:test only — Cannot typecheck TypeScript, render components, or drive a browser; the gate would go formally green while proving almost nothing about the app.
  • A second test runner or entry point alongside validate — Forbidden by AGENTS.md section 3: one gate, or the gate stops being run.

Rationale

Proposed by the agent, approved by the human with the full stack named. This reverses D007's rejection of a test framework — correct at the time, when the repo was dependency-free docs tooling — while preserving D007's actual decision, the single gate.

Consequences

  • When Milestone 1 lands dependencies, the lint rule asserting empty dependency lists must be re-scoped to protect only the docs tooling, or retired with a new decision record.
  • The README validation section gets rewritten when the gate composition changes.
  • User-reported escapes become contract assertions before fixes are implemented (the feedback-loops requirement).

Provenance

Agent proposed: “For validation, I propose Vitest for unit/component/integration tests, Playwright for repeatable browser flows, and agent-browser for real user-surface validation. The milestone gate will run lint, typecheck, tests, production build, and e2e through the single `npm run validate` entry point. Workers will run narrow affected tests first, then the complete gate before handoff.”

Human approved: “Approve Vitest, Playwright, agent-browser”

D021 — Re-scope the dependency ban to an approved pinned allowlist; ESLint covers JS, tsc covers TS

2026-09-08 · build · accepted · Agent decided alone

Problem

D020's first recorded consequence: the lint rule asserting empty dependency lists contradicts the approved application stack the moment Milestone 1 installs it. ESLint also needed a scope decision, because typescript-eslint and eslint-config-next are not in the approved dependency set.

Decision

package.json dependencies and devDependencies are limited to the mission-approved packages at exact pinned versions, enforced by lint against the single allowlist in scripts/lib/approved-deps.mjs (imported by both the lint gate and the integrity tests so they cannot drift). The docs tooling stays zero-dependency, now enforced by a test that scripts/ and docs/dashboard import only node: builtins or relative paths. ESLint lints the JavaScript surface only; TypeScript/TSX correctness is covered by tsc --noEmit. @types/node, @types/react, and @types/react-dom are admitted as part of the approved TypeScript toolchain.

Alternatives considered

  • Keep the empty-dependency lint rule and exempt app code by convention — A rule the gate enforces but the stack violates would be deleted under pressure anyway; re-scoping keeps the protection honest for the docs tooling where it matters.
  • Add typescript-eslint and eslint-config-next for full TS linting — Both are outside the mission-approved dependency set; tsc --noEmit already covers type correctness, and the lint stage stays dependency-light.

Rationale

D020 explicitly deferred this re-scope to the Milestone 1 implementation, and the package set itself was approved during mission planning (D014-D017, D020). Choosing the enforcement mechanics is a mechanical choice inside an approved direction, per AGENTS.md section 4.

Consequences

  • Adding any new package requires editing scripts/lib/approved-deps.mjs and recording a decision.
  • Exact pinned versions only; npm install must run with --save-exact or the gate fails.
  • npm audit reports 4 moderate findings in the drizzle-kit/esbuild toolchain (dev-only transitive deps); the automated fix is a breaking downgrade and is not applied. Recorded as a known weakness in docs/NEXT.md.
  • tsconfig.json and next-env.d.ts are partially maintained by Next.js tooling; tsconfig.json must stay strict JSON so the lint JSON check keeps passing.

Artifacts

  • file scripts/lib/approved-deps.mjs — The single dependency allowlist imported by lint and tests
  • file package.json — The ordered six-stage validate gate and pinned approved dependencies

D022 — Serve /reqs from repository sources with a zero-dependency safe Markdown renderer

2026-09-08 · build · accepted · Agent decided alone

Problem

The approved architecture requires /reqs pages backed by docs/REQUIREMENTS.md, docs/ARCHITECTURE.md, docs/MILESTONES.md, and docs/EVALS.md with raw HTML disabled, and decisions rendered directly from docs/decisions/decisions.json. But the approved dependency allowlist (D021) contains no Markdown package, and hand-copying content into JSX would create a second dataset that drifts from the sources.

Decision

Implement a deliberately small Markdown renderer in src/lib/markdown.ts (headings, paragraphs, flat lists, pipe tables, fenced code, blockquotes, inline code/strong/em/links) that escapes every source character it does not emit itself, allow-lists link targets to https?/root-relative/relative/anchor, renders unsafe or malformed targets as inert text, adds target=_blank rel="noopener noreferrer" plus a visible host label to external links, and makes colliding heading anchors unique with deterministic -2/-3 suffixes. The hub's route table and dogfood URL array are exported constants in src/lib/requirements.ts that pages and tests share; /reqs/decisions imports docs/decisions/decisions.json directly.

Alternatives considered

  • Add react-markdown or marked to the approved set — D021 requires a decision and allowlist change for any new package; the requirements docs use a small fixed subset, so a dependency buys little and expands the supply chain the gate must protect.
  • Hand-write the hub pages as JSX duplicating the docs — Creates the exact second-source drift the architecture forbids; VAL-REQS-002 requires source order to match the repository files.

Rationale

The direction (source-backed /reqs routes, raw HTML disabled, decisions from the JSON) was already fixed by the approved mission architecture; only the mechanism was open. Choosing the mechanism is a mechanical choice inside an approved direction per AGENTS.md section 4, it adds no dependencies, and it is fully reversible, so a unilateral call is safe and is labeled agent-autonomous honestly.

Consequences

  • The supported Markdown subset is deliberately small; new document features require renderer support plus tests.
  • Hostile-input behavior (raw HTML, javascript:/data:/vbscript:/protocol-relative targets, malformed links, colliding anchors) is pinned by test/requirements-markdown.test.ts.
  • Any second decision dataset or duplicated dogfood URL literal is a defect caught by test/requirements-sources.test.ts and test/requirements-decisions.test.tsx.

Artifacts

  • file src/lib/markdown.ts — The safe renderer
  • file src/lib/requirements.ts — The shared route table and dogfood URL array

D023 — Publish one versioned validation boundary catalog as shared exported constants

2026-09-08 · build · accepted · Agent decided alone

Problem

The validation contract (VAL-REQS-007 plus the auth, capture, quota, geometry, and performance assertions) requires exact versioned values for session lifetime/renewal, URL limits and normalization fixtures, capture dimensions/time/bytes/attempts/concurrency/staleness, the manifest schema, the supported-motion matrix and tolerances, the capture outcome catalog, geometry minimums, login/reply quotas, the client timeout, the annotation maximum, hit targets, and the performance protocol/budgets — before the features that consume them exist. Without a single exported source, each consuming feature would invent and duplicate its own literals, and the docs and /reqs pages would silently drift from runtime behavior.

Decision

Create src/lib/boundaries/ as the only source of runtime policy values: focused modules (session, url, capture, manifest, motion, outcomes, geometry, quotas, feedback, interaction, performance) re-exported under one dated POLICY_VERSION (2026-09-08.1). Choose the concrete values now: a 12-hour renewable editor session with a 2-hour renewal threshold; 32 submitted rows, 16 unique URLs, 2,048 bytes per URL; 1440×900 and 390×844 DPR-1 viewports; 16,384 px height, 25,000,000 px area, 8 MiB image, 16 MiB provider response caps; 30 s navigation, 5 s network-idle, 800 px × 24-step × 250 ms lazy scroll, and a 90 s total capture deadline inside Browserless's 120 s session cap; 5 redirect hops; 64 attempts per project; 2 active captures; a 5-minute stale age; a 500-element / 256 KiB exact-key manifest schema; an eight-case motion matrix with 1 px anchor tolerance and 0.001 masked-diff ratio; an eighteen-code outcome catalog with 256-byte public messages; 8 px minimum shapes and 16 px minimum arrows; 5 failed logins per 15 minutes and 30 replies per hour; a 15 s client request timeout; 2,000-character feedback bodies; 200 annotations per capture; 8 nearby candidates; 24 px hit targets (WCAG 2.2 AA); and the tall-capture performance protocol and budgets. Publish the same values, fixtures, and policy enums in docs/EVALS.md and docs/ARCHITECTURE.md (which the /reqs routes render), and pin all three together with test/boundaries.test.ts, which imports the exported constants, checks the docs and rendered route HTML for the exact values, and scans the application for duplicated literals.

Alternatives considered

  • Defer the exact numbers to each consuming feature — VAL-REQS-007 requires published exact values before the dependent behavior lands; deferring re-creates the drift and guesswork the catalog exists to prevent, and each feature would choose in isolation.
  • Maintain the values in the docs and mirror them into code — Two writable sources inevitably drift; instead the code exports the values once and the docs are pinned to the exports by test.

Rationale

The mission plan explicitly assigns defining this catalog to the validation-boundary-catalog feature, and the requirements session deliberately published policies without numbers until it landed. The values are constrained by documented provider limits (Browserless's two concurrent sessions and 120-second cap), the observed ~13,000 px Chickpea mobile page, WCAG 2.2 target-size minimums, and the approved architecture. Choosing them here is the assigned work, the choice is fully recorded, and it is reversible by editing one module, so a unilateral call is safe and is labeled agent-autonomous honestly.

Consequences

  • Auth, capture, canvas, thread, UI, and performance features must import from src/lib/boundaries/ rather than declaring literals; the duplicate-literal scan in test/boundaries.test.ts fails otherwise.
  • docs/EVALS.md and docs/ARCHITECTURE.md table rows are formatted to the drift test's conventions; changing a value requires updating the constant and the docs together.
  • URL normalization is pinned by 22 exact fixtures, including the policy steps WHATWG does not perform (trailing-dot strip, fragment removal, empty-query drop) and the distinctness of %7E versus ~ and of /pricing versus /pricing/.
  • Any boundary change bumps POLICY_VERSION and updates both docs in the same commit.

Artifacts

  • file src/lib/boundaries/index.ts — The versioned catalog entry point
  • file test/boundaries.test.ts — The source/docs/route drift guard and duplicate-literal scan
  • file docs/EVALS.md — The published boundary tables, rendered at /reqs/evals

D024 — Verify the editor password via fixed-length digests and bind sessions to a double-submit CSRF proof

2026-09-08 · build · accepted · Agent decided alone

Problem

The editor login (VAL-AUTH-001, VAL-AUTH-010) needs a server-only verifier that never leaks length or timing information about EDITOR_PASSWORD, a session format that supports the catalog's absolute-expiry/renewal policy plus authoritative logout before any durable store exists, and CSRF protection for cookie-authorized mutations now that SameSite=Strict and exact Origin checks alone would leave the later mutation surface without a session-bound proof.

Decision

Hash both the submitted password and EDITOR_PASSWORD with SHA-256 and compare the fixed-length digests with crypto.timingSafeEqual, so empty, unequal-length, oversized, and arbitrary-Unicode input can neither throw nor bypass. Issue sessions as HMAC-SHA256-signed v1.payload.signature tokens carrying a random session id, issued-at, absolute expiry, and a random CSRF proof; renewal keeps the session id and proof and resets the absolute expiry. Bind mutations with a double-submit pair: the proof rides in a browser-readable pinata_csrf cookie and must be echoed in the x-pinata-csrf header, compared timing-safely against the session payload. Logout revokes the session id in a per-process revocation set held until the session's absolute expiry, and always clears both cookies with matching attributes. Enforce exact same-origin Origin against the Host header (Next.js normalizes request.url's hostname), the 1,024-byte auth body cap, and a strict one-field Zod schema; add AUTH_REQUEST_MAX_BYTES and EDITOR_PASSWORD_MAX_CHARS to the boundary catalog and bump POLICY_VERSION to 2026-09-08.2. Keep every secret, verifier, and cookie serializer in src/lib/server/, guarded by a test that fails if any client module imports them.

Alternatives considered

  • Compare plaintext passwords with timingSafeEqual after length checks — Length checks branch on attacker input and expose the configured length; hashing to fixed-length representations first keeps one constant-time code path for every input class.
  • Rely on SameSite=Strict plus Origin checks without a CSRF token — The contract requires a session-bound CSRF proof on authenticated mutations; the double-submit header also guards against future relaxed-same-site mistakes and subresource confusion.
  • Wait for the Turso schema feature and store sessions/revocations in the database — Login must work before the database lands; a per-process revocation set satisfies authoritative logout within an instance now, and the durable-throttling feature can move revocation and buckets to Turso without changing the token format or the route contracts.

Rationale

The architecture document already directs the password prompt, timing-safe comparison, SESSION_SECRET-signed cookies, and origin/CSRF protection, so this chooses only implementation mechanics inside that approved direction — a safe agent-autonomous call. The verifier and session format were proven with 44 focused tests before the full gate ran.

Consequences

  • Client code reads the pinata_csrf cookie and echoes it in x-pinata-csrf on every mutation; the header is compared against the session payload, not the cookie, so a stolen cookie alone cannot authorize mutations.
  • Logout is authoritative per application instance; cross-instance revocation durability arrives with the durable store features (editor-durable-login-throttling, editor-session-lifecycle-on-protected-data).
  • AUTH_REQUEST_MAX_BYTES (1,024 bytes) and EDITOR_PASSWORD_MAX_CHARS (256) join the versioned boundary catalog; docs/EVALS.md and docs/ARCHITECTURE.md publish them at POLICY_VERSION 2026-09-08.2.
  • No client-reachable module may import src/lib/server/ or node:crypto; test/server/auth.test.ts enforces the boundary by source scan.

Artifacts

  • file src/lib/server/auth/password.ts — Fixed-length timing-safe verifier
  • file src/lib/server/auth/session.ts — Signed, renewable, revocable session tokens
  • file src/lib/server/http.ts — Origin, byte-cap, and generic-error boundaries
  • file test/server/auth-routes.test.ts — The login/logout/session denial matrix

D025 — Persist the canonical model in committed Drizzle migrations with database-enforced thread immutability and injectable provider seams

2026-09-08 · build · accepted · Agent decided alone

Problem

Every capture, annotation, thread, sharing, and throttling feature needs one authoritative Turso/libSQL schema whose constraints (unique normalized pages, immutable capture attempts, append-only thread entries, capability digests, idempotency keys, durable rate-limit buckets) hold at the database boundary rather than by application convention, applied through committed repeatable migrations, with provider boundaries that focused tests can drive deterministically without letting mocks replace real Turso/Blob/Browserless proof.

Decision

Model the architecture's tables in src/lib/server/db/schema.ts (text UUID keys, epoch-ms UTC timestamps, explicit foreign keys, unique constraints, and CHECK constraints for capture variant/status, annotation kind, and thread role/label), generate committed SQL with drizzle-kit into drizzle/, and add a custom migration installing BEFORE UPDATE/DELETE triggers on thread_entries that RAISE(ABORT). Apply migrations with scripts/db-migrate.mjs (npm run db:migrate), a Node script using drizzle-orm's libSQL migrator over @libsql/client so reapplication is idempotent and credentials are never printed. Capture attempts carry a per-(page, variant) monotonic attempt number plus a unique idempotency key and a unique blob_path, so retries are new immutable rows. Add generic idempotency_keys ((scope, key) primary key plus payload digest) and digest-keyed rate_limit_buckets tables. Keep all database, Browserless, and private-Blob construction in server-only modules with dependency-injectable client/fetch/SDK seams; adapters map provider failures to bounded secret-free error codes and never cross provider URLs or tokens to callers.

Alternatives considered

  • drizzle-kit push or manual schema changes against Turso — The architecture requires committed, repeatable migrations; push/manual mutation leaves no auditable artifact and cannot be replayed identically in CI or production.
  • Enforce thread append-only behavior only in application code — VAL-THREAD-002 requires database triggers rejecting UPDATE/DELETE; application-only enforcement can be bypassed by any future code path or manual session.
  • Let thread entries carry ON DELETE CASCADE so validation cleanup can delete them — Cascade would let an annotation hard-delete erase founder history, contradicting the immutability rule; tests instead prove triggers inside a rolled-back transaction so no immutable row is ever left behind.

Rationale

The architecture document already directs Turso/libSQL + Drizzle, committed migrations, the table set, digest-only capability storage, and database triggers; this record chooses only mechanics inside that approved direction (migration runner, attempt-number/idempotency columns, trigger SQL), which is safe to decide unilaterally. The schema, triggers, and provider seams were verified against the real configured Turso database and private Blob store with disposable run-scoped data and confirmed cleanup before the full gate ran.

Consequences

  • Future schema changes flow through drizzle-kit generate plus npm run db:migrate; drizzle/meta snapshots are committed and hand edits to generated SQL are limited to appended custom statements before first application.
  • Retrying a capture inserts a new row with the next attempt number; blob_path uniqueness forces a fresh private object per attempt, which capture features rely on for late-result fencing (VAL-CAPTURE-008).
  • Rate-limit and idempotency callers must SHA-256 their scope + identifier into bucket/digest keys; no plaintext password or raw capability may key a row.
  • Focused tests inject in-memory libSQL databases, fake fetch, and fake Blob SDKs for deterministic fault coverage; test/integration/turso.integration.test.ts runs the real-provider checks whenever the environment is present and skips otherwise.
  • No client-reachable module may import src/lib/server/db or src/lib/server/providers; test/server/provider-boundaries.test.ts enforces the boundary by source scan.

Artifacts

  • file src/lib/server/db/schema.ts — Canonical Drizzle schema
  • file drizzle/0001_thread_entries_immutable.sql — Append-only thread triggers
  • file scripts/db-migrate.mjs — Idempotent migration runner
  • file src/lib/server/providers/blob.ts — Private Blob boundary with injectable SDK
  • file test/integration/turso.integration.test.ts — Real Turso/Blob verification with verified cleanup

D026 — Throttle editor logins with one durable digested global bucket and check secrets fail-closed before verification

2026-09-08 · build · superseded · Agent decided alone

Problem

VAL-AUTH-006 requires durable editor-login throttling that holds across tabs and at least two application instances, recovers after the exact published interval, and keeps passwords and secrets out of throttle evidence, plus fail-closed behavior when either editor auth secret is missing. The contract fixes the threshold (LOGIN_MAX_FAILURES) and window (LOGIN_WINDOW_MS) but not the bucket identity, window style, throttled-response shape, or misconfiguration ordering.

Decision

Enforce the login throttle in the approved Turso rate_limit_buckets table as one shared bucket keyed by the SHA-256 digest of the fixed editor-login scope (never a password, secret, or client identifier), with a fixed window anchored at the first failure: failures are registered by a single atomic INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING statement that resets the window exactly at the boundary, throttled attempts receive a bounded generic 429 with a Retry-After header and never mutate or extend the window, a successful login deletes the bucket, and the route fails closed (bounded 503) when the durable store or SESSION_SECRET is unavailable. The SESSION_SECRET check runs before password verification so a misconfigured deployment answers every attempt with the identical 503 instead of becoming a password-correctness oracle. Tests and validation runs use run-scoped scopes so they never touch the production bucket.

Alternatives considered

  • Per-client-IP buckets keyed from x-forwarded-for / x-real-ip — The editor credential is a single shared secret, so IP-keyed buckets let a distributed attacker keep guessing by rotating addresses, and client-supplied forwarding headers are spoofable off-platform; one global bucket is the strongest reading of the contract requirement that the same bucket hold across tabs and instances.
  • Sliding window or throttled-attempts-extend-the-window — Only a fixed window anchored at the first failure makes 'a correct attempt succeeds immediately after the exact recovery interval' literally true; extending the window under sustained attack would make recovery unpredictable.
  • Keep the pre-existing order that verifies the password before checking SESSION_SECRET — With SESSION_SECRET absent and EDITOR_PASSWORD present that order answers 401 to wrong passwords and 503 to the right one, leaking password correctness from a misconfigured deployment.

Rationale

VAL-AUTH-006 and the approved schema (D025) already direct durable, digest-keyed throttling; this record chooses only the keying scope, window semantics, response shape, and check ordering inside that approved direction, which is safe to decide unilaterally because no user-facing product direction changes. The behavior was proven against the real configured Turso database (two independent clients sharing one run-scoped bucket, digest-only readback, verified cleanup) and end to end against the local server across a process restart before the full gate ran.

Consequences

  • Five wrong editor passwords anywhere in the world throttle all editor login attempts for up to the published 15-minute window; this deliberately trades editor availability for credential protection and is published in docs/EVALS.md.
  • The login route depends on the durable store: when Turso is unreachable, login fails closed with a bounded 503 rather than allowing unaccounted attempts.
  • CI environments without Turso credentials cannot exercise the login route; the e2e auth specs already require .env.local secrets and remain a local/deployed-surface check.
  • Validation runs correlate durable throttle state by recomputing the SHA-256 of their run-scoped scope and must delete the row afterward; the production scope is reserved for real traffic.
  • Future reply throttling (VAL-THREAD-006) should reuse registerLoginFailure-style atomic upsert semantics with its own scope.

Artifacts

  • file src/lib/server/auth/throttle.ts — Durable digest-keyed login throttle with atomic window reset
  • file app/api/auth/login/route.ts — Login route wiring: throttle pre-check, fail-closed secrets, failure accounting
  • file test/server/login-throttle.test.ts — Focused threshold, recovery-boundary, and digest-only bucket tests
  • file test/server/auth-throttle-routes.test.ts — Route-level throttle and fail-closed configuration matrix
  • file test/integration/login-throttle.integration.test.ts — Real Turso cross-instance proof with run-scoped cleanup

Superseded by D098.

D027 — Env-dependent tests skip rather than fail, so the CI gate needs no repository secrets

2026-09-08 · build · accepted · Agent decided alone

Problem

The gate is one command run in two places with different configuration. Locally .env.local supplies the editor secrets, Turso credentials, Blob token, and Browserless token; GitHub Actions holds none of them. The e2e auth specs read EDITOR_PASSWORD from .env.local and threw when it was absent, so `npm run validate` could never pass in CI. Either CI gets real secrets, or the suite has to state which parts it can prove without them.

Decision

Keep secrets out of CI entirely and make configuration-dependent tests skip with a name-only reason instead of failing. Playwright specs go through e2e/local-env.ts: localEnvGate([...names]) resolves each variable from the process environment first and .env.local second, reports the missing names, and the spec calls test.skip(!gate.ready, gate.reason) before reading any value through requireLocalEnvValue. No spec reads .env.local directly, and test/e2e-env-gate.test.ts enforces both that rule and the presence of the skip. This mirrors the describe.skipIf gating the Vitest integration suites already use. The unauthenticated e2e coverage — the login prompt shape, the 401 on the protected read, and the public HTML/bundle secret-name scan — stays unconditional and runs in CI.

Alternatives considered

  • Give the workflow real repository secrets — It would put the editor password, session-signing key, and provider tokens into a workflow that also runs on pull requests, for no gain in what CI actually proves; the credentialed paths still need a real browser and a real deployment to be believable.
  • Split e2e into a CI subset and a local-only suite with a second command — A second entry point breaks the rule that one command means the same thing everywhere, and it invites the local-only suite to rot unrun.
  • Have the specs fabricate a password when the environment is absent — A test that passes against a credential nobody configured proves nothing and would report false coverage of the auth boundary.

Rationale

This is the gating pattern the repository already chose for the Turso and Blob integration suites, applied to Playwright, so it introduces no new direction; it is safe to decide unilaterally because it neither weakens an assertion nor changes product behavior. The skipped tests are named in the run output with the variables they need, which keeps the reduced CI coverage visible instead of silent, and no secret value reaches the workflow, the specs, or any committed file.

Consequences

  • A green CI run proves the public and anonymous surfaces only. Editor login, Turso, Blob, and Browserless coverage comes from a local `npm run validate` with .env.local present, and from validation against the deployment — CI alone is never sufficient evidence that a credentialed path works.
  • Every future env-dependent Playwright spec must gate through e2e/local-env.ts; reading .env.local directly now fails `npm test`.
  • Skip reasons and gate errors may name variables but never values, keeping the no-secret-in-output rule intact even in failure output.

Artifacts

  • file e2e/local-env.ts — The shared Playwright environment gate: name-only reporting, process env before .env.local
  • file e2e/auth.spec.ts — Auth specs: anonymous checks unconditional, the two login checks gated
  • file test/e2e-env-gate.test.ts — Gate behavior plus the repository rule that no spec reads .env.local directly

D028 — Projects take an explicit URL array; admission is a synchronous, network-free normalizer

2026-09-08 · build · accepted · Agent decided alone

Problem

A project needs more than one page, and the obvious way to get them is to crawl the root. Crawling is out of scope by product framing (the landing copy promises 'no crawling'), it turns project creation into an unbounded network operation, and it makes the page set non-deterministic. The creation boundary still has to decide, for every submitted string, whether it is a capturable destination — and it has to decide the same way every time so page identity is stable.

Decision

Project creation accepts exactly one required root URL plus an optional explicit array of additional URLs, and never discovers, infers, or follows a link. Every string goes through one synchronous normalizer (src/lib/url/normalize.ts) that performs no I/O: trim, byte cap, scheme check, a single WHATWG parse, https-only, no credentials, no non-443 port, trailing-dot strip, IP-literal rejection, empty-label rejection, single-label and reserved-suffix rejection, fragment dropped, empty query dropped, empty path normalized to '/'. The result is the page's identity, so two spellings that differ only by fragment collapse to one page. Every limit and every reject reason lives in the versioned boundary catalog (POLICY_VERSION 2026-09-08.3) with fixtures, and the route returns all offending rows at once as {field, index, code} without echoing the submitted text.

Alternatives considered

  • Crawl the root and offer discovered pages — Contradicts the product's stated 'no crawling' promise, makes creation an unbounded network operation with its own failure and abuse surface, and yields a page set that changes between two runs against the same site.
  • Resolve DNS at admission to prove the host is public — It makes a form submission depend on the network, is trivially defeated by rebinding between admission and capture, and duplicates the check the capture worker has to make anyway at fetch time.
  • Accept any URL and let capture fail later — It converts a correctable typo into a persisted project with dead pages and pushes SSRF-shaped inputs deeper into the system before anything says no.

Rationale

This is the conservative reading of an existing product constraint rather than a new direction, so it was safe to decide unilaterally. Keeping admission synchronous and network-free means the boundary is fully testable from fixtures, the same function decides page identity and admission (so they cannot drift), and the genuinely network-dependent checks stay where they can be enforced — at capture time, against the address actually connected to.

Consequences

  • Users must paste the pages they care about; there is no discovery affordance, and the editor UI is therefore an add/remove/reorder row list rather than a picker.
  • A host that resolves to a private address still passes admission. Rebinding and redirect safety are the capture worker's job (VAL-CAPTURE-001/002), and that split is now load-bearing.
  • Any change to normalization changes page identity, so it must bump POLICY_VERSION and update the fixtures and docs the drift tests compare against.

Artifacts

  • file src/lib/url/normalize.ts — The synchronous admission normalizer: no I/O, one parse, one reject reason per failure
  • file src/lib/boundaries/url.ts — Versioned limits, reject reasons, and the normalization fixtures the docs and tests share
  • file test/url-normalization.test.ts — 79 cases: fixtures, byte caps, hostile spellings, and page-identity collapse

D029 — A project, its pages, and two pending capture attempts per page are created in one transaction, keyed for idempotent retry

2026-09-08 · build · accepted · Agent decided alone

Problem

Creating a project writes to three tables, and capture dispatch reads what it wrote. A partial write leaves a project with no pages, or pages with no attempt rows that nothing will ever pick up — states the UI cannot represent and the capture worker cannot recover from. A double-submit or a retried request after a dropped response would otherwise create a second identical project.

Decision

One db.transaction writes the idempotency record, the project, every page in submission order (root first), and exactly two pending capture rows per page (desktop 1440x900, mobile 390x844, DPR 1, attempt 1) — or nothing. Capture dispatch happens strictly after the transaction commits. The client sends an idempotency key per creation intent: the same key with the same canonical payload digest replays the original identities with created:false and HTTP 200, the same key with a different digest is refused with 409, and a transaction failure re-reads the idempotency record so a concurrent winner converges instead of both callers failing.

Alternatives considered

  • Write the rows sequentially and repair on the next read — Repair logic has to guess which of several partial shapes it is looking at, and every reader — UI, capture worker, share links — would need the same guess.
  • Deduplicate on the payload alone, with no client key — Two deliberate projects over the same URL set are legitimate; collapsing them silently loses user intent, and the payload alone cannot distinguish a retry from a second attempt.
  • Create the attempt rows lazily when capture first runs — It leaves a window where a project exists with nothing queued, so a crash between creation and dispatch strands the project with no evidence that work was ever owed.

Rationale

Atomicity plus an explicit key is the standard shape for a create-then-dispatch boundary and introduces no product direction, so it was safe to decide unilaterally. Writing the attempt rows inside the same transaction makes 'work is owed' a durable fact rather than an in-flight intention, which is what lets the capture worker be a plain queue reader and lets partial-status reporting be a query instead of an inference.

Consequences

  • Capture dispatch may assume every page already has exactly two pending attempt rows; it never creates them.
  • Clients must generate one idempotency key per creation intent and reuse it on retry — the editor form does this and refreshes the key only after a success or a conflict.
  • The variant matrix (desktop/mobile, their viewports and DPR) is fixed at creation time, so adding a variant later means a migration for existing projects, not just new code.

Artifacts

  • file src/lib/server/projects/create.ts — The single transaction plus replay, conflict, and concurrent-winner convergence
  • file test/server/projects-create.test.ts — Atomicity, rollback-leaves-nothing, replay identity, and lost-race convergence
  • file test/integration/projects.integration.test.ts — The same guarantees against the real Turso database, with verified cleanup

D030 — The active capture is the highest-numbered ready attempt, and staleness is computed at read time

2026-09-08 · build · accepted · Agent decided alone

Problem

Attempt rows are immutable history, so a page variant can hold several of them at once: a ready one, a newer failed one, and an abandoned capturing one whose worker died. Something has to decide which version the canvas shows and when a retry is offered. Choosing by completion time would let a late result from an abandoned older attempt overwrite a newer ready capture as the default, silently rebasing every annotation bound to it. Persisting a stale flag would need a background job the deployment does not have.

Decision

Selection is by attempt version, never by clock: the active capture for a (page, variant) is the ready attempt with the highest attempt number, so a late old result still persists on its own row and stays addressable but can never become the default. A capturing attempt older than STALE_CAPTURE_AGE_MS computes to stale on read rather than being written, and retry is offered only when the latest attempt is terminal or computed stale and the outcome catalog does not mark that failure non-retryable. Every persisted transition is a compare-and-set on one attempt id plus its expected status, so terminal rows can never be rewritten.

Alternatives considered

  • Select the most recently completed attempt — An abandoned attempt that reports back ten minutes late would displace the newer capture the user is already annotating.
  • Persist a stale flag with a sweeper job — It needs a scheduler the Vercel deployment does not run, and a missed sweep leaves an attempt permanently unretryable.
  • Overwrite the attempt row on retry — It destroys the image, manifest, hash, and annotation binding of the previous version, which the architecture requires to stay addressable.

Rationale

Version-ordered selection plus read-time staleness makes both answers pure functions of committed rows, so two readers, two instances, and a reload cannot disagree, and no background process is required. This is an implementation of the immutability rule the architecture already fixed, not a product choice, so it was safe to decide unilaterally.

Consequences

  • A late result is never lost and never wins: it lands on its own row and appears in the version list below the newer default.
  • Staleness moves with the published constant; changing STALE_CAPTURE_AGE_MS changes retryability everywhere at once with no data migration.
  • Any future capture worker must transition through applyCaptureTransition, because a direct update would bypass the terminal-row fence.

Artifacts

  • file src/lib/server/captures/status.ts — Computed state, version-ordered selection, and retryability
  • file src/lib/server/captures/transitions.ts — Compare-and-set transitions that fence terminal rows and lost races
  • file test/server/capture-status.test.ts — Selection, staleness, and retryability boundaries

D031 — Retry is scoped to one page and one viewport, keyed to that exact target

2026-09-08 · build · accepted · Agent decided alone

Problem

When one of a project eight captures fails, resubmitting the project would re-run the seven that succeeded, burn Browserless quota, and replace ready captures that already carry annotations. A retry also needs a key so a double click or a retried request after a dropped response does not schedule two captures — but a key that is not bound to its target would let the same key silently schedule a different page or viewport.

Decision

POST /api/pages/[pageId]/captures names exactly one page and one variant. It writes one new pending attempt for that target only, never touching a sibling page or the other viewport. The client idempotency key is recorded in the shared idempotency_keys table under a capture-retry scope with a digest of {pageId, variant}: the same key with the same target replays the one created attempt, and the same key with a different target is refused with a bounded 409. Attempts are capped per project by MAX_CAPTURE_ATTEMPTS_PER_PROJECT, and a missing page, a non-retryable state, and a quota refusal all answer with the same generic bounded message.

Alternatives considered

  • Retry at the project level — It re-captures ready siblings, wastes the two-concurrent free tier, and creates new versions nobody asked for.
  • Key the retry on (page, variant) alone with no client key — Two deliberate recaptures of the same target are legitimate; collapsing them removes the ability to recapture at all.
  • Let the key be target-free — A reused key would then schedule work against whatever target the request happened to name, which is exactly the confusion idempotency is supposed to prevent.

Rationale

Scoping the mutation to the smallest addressable unit is what makes partial failure recoverable without collateral damage, and binding the key to the target keeps replay honest. Both follow directly from the approved capture model, so no product direction was decided here.

Consequences

  • The capture worker remains a queue reader: retry, like creation, only writes pending rows and never calls a provider inline.
  • A client must hold one key per retry intent and refresh it only after a success or a conflict; the workspace does this per (page, variant).
  • CAPTURE_REQUEST_MAX_BYTES joins the versioned boundary catalog (POLICY_VERSION 2026-09-08.4) so the retry body cap is published like every other limit.

Artifacts

  • file src/lib/server/captures/retry.ts — Target-bound idempotent retry with the project attempt cap
  • file app/api/pages/[pageId]/captures/route.ts — The scoped retry endpoint and its bounded denials
  • file test/integration/hierarchy.integration.test.ts — Real-Turso proof of scoped retry, replay, conflict, and late-result fencing

D032 — Capture admission is two defences: a bounded server-side check and an in-function request guard

2026-09-08 · build · accepted · Agent decided alone

Problem

Project creation admits URLs without touching the network, so a public hostname that resolves to a private address passes it. Capture then hands that hostname to Browserless, which runs in a different network from this application. A single application-side DNS check is not proof of anything the remote browser will do a moment later, and a purely remote check gives the application no bounded verdict before it spends provider quota.

Decision

Capture admission runs in two places. Server-side, admitCaptureTarget canonicalizes once with WHATWG URL, rejects every unsafe syntax and IP spelling, resolves a bounded CNAME chain plus A and AAAA under DNS_TIMEOUT_MS, refuses the host if any answer is non-public, then walks the redirect chain with redirect: manual, revalidating every top-level hop under the identical rules up to MAX_REDIRECT_HOPS. Only then is the attempt claimed as capturing with its requested and final public URL persisted. Inside the Browserless function, an emitted request guard revalidates every top-level navigation and aborts subresource requests to credentialed hosts, reserved hosts, non-HTTP(S)/WebSocket schemes, and IP-literal hosts, without disabling web security, TLS validation, sandboxing, or the provider blocklist.

Alternatives considered

  • Trust the application-side DNS check alone — It cannot see a rebind, and the browser resolves the name again from a different network.
  • Trust the provider private-network blocklist alone — It gives the application no bounded pre-provider verdict, so an unsafe target would still consume quota and produce an attempt with no explanation.
  • Validate only the initial URL and let the browser follow redirects — A public first hop redirecting to 169.254.169.254 is the exact attack this boundary exists to refuse.

Rationale

Neither side is sufficient alone and the two fail in different directions, so running both is what makes the boundary defensible. Both sides follow from the approved architecture, so no product direction was decided here.

Consequences

  • Rejected targets never reach a provider: dispatch fails the attempt with a catalog outcome before a Browserless client is built.
  • A safe redirect chain persists both the requested and the final public URL on the claimed attempt.
  • DNS_TIMEOUT_MS, MAX_CNAME_HOPS, REDIRECT_PROBE_TIMEOUT_MS, and the non-public address range catalog join the versioned boundary catalog (POLICY_VERSION 2026-09-08.5).
  • POST /api/captures/[captureId]/dispatch claims an admitted attempt as capturing; the provider execution that turns it into ready lands in the following capture feature, and until then an admitted attempt reaches computed stale and stays retryable.

Artifacts

  • file src/lib/server/captures/admission.ts — Canonicalize, bounded DNS, and redirect-hop revalidation
  • file src/lib/server/captures/guard.ts — The emitted Browserless in-function request guard
  • file app/api/captures/[captureId]/dispatch/route.ts — Admission before any provider work

D033 — DNS admission fails closed on any ambiguity, and one non-public answer rejects the host

2026-09-08 · build · accepted · Agent decided alone

Problem

A resolver can answer in more ways than yes and no. A host may return one public A record and a private AAAA record, a SERVFAIL for one family and an answer for the other, two CNAMEs, a chain that loops, or nothing at all within the budget. Treating any of those as good enough leaves a path where the browser picks the answer we did not check.

Decision

A host is admitted only when every answer parses as an address and every answer is public. A timeout, an ambiguous failure such as SERVFAIL or REFUSED from either family, more than one CNAME, a loop, a chain longer than MAX_CNAME_HOPS, an unparseable answer, and an empty result all reject the host. A definitive ENODATA/NXDOMAIN for one family is the one non-answer that is treated as information rather than ambiguity, so an IPv4-only host still resolves. Rejections report a bounded reason and never the resolved address.

Alternatives considered

  • Admit the host if any family answers publicly — The browser may prefer the family whose answer we could not read, which is the rebinding case restated.
  • Retry ambiguous answers until one resolves — It turns an unbounded resolver into an unbounded capture, and a determined attacker controls how long that lasts.
  • Report the matched address or range in the error — That turns the rejection into an internal-network oracle for anyone who can submit a URL.

Rationale

Every rejected case is one where the application cannot state what the browser will connect to, and the cost of refusing is one retryable failed attempt. The address catalog is published as enumerated policy so the refusal is auditable without disclosing any particular answer.

Consequences

  • A misconfigured but genuinely public host can be refused; the outcome is dns-failed, which the catalog marks retryable.
  • The non-public range catalog is the single executable source for both the address classifier and the published docs, so adding a range is one catalog edit plus its published rows.

Artifacts

  • file src/lib/server/captures/dns.ts — Bounded, fail-closed CNAME/A/AAAA resolution
  • file src/lib/net/address.ts — Prefix-match classification against the published range catalog
  • file src/lib/boundaries/network.ts — The published DNS budget and non-public address ranges

D034 — Capture screenshots are PNG, and only PNG or WebP may ever be stored

2026-09-09 · build · accepted · Agent decided alone

Problem

The capture pipeline has to decide what image format it asks Chromium for and what it will accept back. The choice is not cosmetic: the motion assertion compares two captures of one deterministic fixture pixel by pixel, and it also decides what a decoder has to be able to reject safely.

Decision

The published catalog gains ALLOWED_IMAGE_CONTENT_TYPES = [image/png, image/webp], and the first entry is what the capture function asks for. Only an allowlisted declared content type whose bytes decode as that same format, at exactly the document dimensions, can be stored. POLICY_VERSION moved to 2026-09-08.6.

Alternatives considered

  • Capture lossy WebP for smaller objects — Lossy encoding makes two captures of an identical page differ, which is exactly what the stabilization assertion measures.
  • Accept whatever the provider returns — A provider error page, an HTML body, or a polyglot would become a ready capture; the decoder has to gate on a closed set.

Rationale

This is a mechanical choice inside an already approved direction, and it is reversible: WebP stays in the allowlist so a future feature can switch the produced format without touching the validator. PNG is lossless, so the pixel-diff assertion measures the page rather than the encoder, and the structural decoder that validates it is small enough to stay dependency-free.

Consequences

  • Screenshots are larger than a lossy encoding would be; a 1440 x 4484 fixture capture measured 188 KB, well inside the 8 MiB cap.
  • Adding an image format is a five-file catalog change: the constant, both published docs, the drift test row, and POLICY_VERSION.

Artifacts

  • file src/lib/boundaries/capture.ts — The published image-type allowlist
  • file src/lib/server/captures/image.ts — Structural PNG/WebP validation, dimension agreement, and SHA-256

D035 — Dispatch admits, captures, and finalizes in one request; there is no claim endpoint

2026-09-09 · build · accepted · Agent decided alone

Problem

Admission claimed an attempt and returned 202 with the row left in capturing, on the assumption that some later call would run the provider. That leaves a durable open claim whenever the second call never arrives, and it invites a second public endpoint whose only job is to finish someone else's claim.

Decision

POST /api/captures/:id/dispatch now runs the Browserless execution in the same request, immediately after dispatchCapture returns ok, and answers only once the row is ready or failed. Every exit from a claimed attempt is terminal: a missing provider credential, an untrustworthy envelope, a storage failure, and an unexpected throw all fail the row with a catalog outcome rather than leaving it capturing.

Alternatives considered

  • Keep 202 and add a worker or claim endpoint — It adds a second surface that finalizes attempts it did not admit, and the open claim still exists whenever that worker is not running.
  • Leave the claim open and rely on stale-lease reconciliation — Staleness is a five-minute recovery path for crashes, not a design for the normal case.

Rationale

The open claim was recorded as an open question by the previous feature, and closing it inside the existing endpoint changes no public contract beyond the success status. Deciding this unilaterally was safe because the alternative — a new endpoint — is the change that would have needed approval.

Consequences

  • A successful dispatch response now takes as long as a real capture, which the request-level timeout budget already bounds.
  • An admitted attempt can never be observed as an open capturing claim from this route, so polling only ever sees a terminal row.

Artifacts

  • file app/api/captures/[captureId]/dispatch/route.ts — Admit, capture, finalize, respond
  • file src/lib/server/captures/execute.ts — Every path out of a claim is terminal

D036 — Browserless is authenticated with HTTP basic, because bearer fails and the URL is not an option

2026-09-09 · build · accepted · Agent decided alone

Problem

The provider documents its token as a query parameter, which this project refuses: a credential in a URL leaks into proxies, logs, and error reports. The adapter therefore sent it as a bearer credential in the Authorization header — and every real call failed. Measured against three regional endpoints, a bearer credential returns a gateway 500 while the same request with the token in the query string returns 200.

Decision

The adapter sends Authorization: Basic base64(token + ':') — the token as the basic username with an empty password. That form is accepted by the same gateway that rejects bearer, and it keeps the credential in the header. The fixed regional /function endpoint and the never-log rule are unchanged.

Alternatives considered

  • Put the token in the ?token= query string as documented — URLs end up in proxy logs, error reports, and stack traces; a header does not.
  • Stop and treat the provider as blocked — The provider is not blocked — one header form works, and the constraint is theirs, not the design's.

Rationale

This preserves the invariant that matters (the credential travels only in the Authorization header, never in a URL and never in a log) and changes only the scheme token inside that header. It was safe to decide alone because the alternative that needed judgement — a credential in the URL — was rejected, not chosen.

Consequences

  • Provider-adapter tests assert the basic form and that the raw token appears nowhere in the header value or the URL.
  • If the provider later accepts bearer, switching back is a one-line change behind browserlessAuthorization().

Artifacts

  • file src/lib/server/providers/browserless.ts — browserlessAuthorization(): the accepted header form

D037 — Controlled capture fixtures live in the repository and are published to a disposable public host per run

2026-09-09 · validate · superseded · Agent decided alone

Problem

Proving device emulation, context isolation, lazy loading, and motion stabilization needs pages that Browserless can actually load, which means public HTTPS. This project's own Vercel deployments sit behind deployment protection and answer an SSO redirect, no tunnel tooling is installed, and publishing fixtures through the application's own routes would put test pages on its public surface.

Decision

The fixtures are versioned files in test/fixtures/capture/ (echo-v1.html, tall-motion-v1.html). scripts/publish-capture-fixtures.mjs uploads them to a disposable public host with a one-hour lifetime, refuses to print a URL unless the served bytes hash to exactly the repository bytes and arrive as text/html, and the real-provider suite reads those URLs from the environment and skips when they are absent.

Alternatives considered

  • Serve the fixtures from the application on Vercel — Deployment protection blocks the provider, and disabling it to host test pages trades a real safety control for test convenience.
  • Commit the fixtures to a public branch and serve them through a CDN of raw repository files — It requires pushing, and this worker does not push.
  • Run a tunnel to the local server — No tunnel client is installed, and installing one puts a network-exposing daemon on the machine for a test.

Rationale

The repository stays the source of truth for fixture behaviour — the host only serves a byte-identical copy for the length of a run — so the assertion remains reproducible from the tree. The uploaded content is inert markup with no secrets and no application data, and it expires on its own.

Consequences

  • The real-provider capture suite needs one publish step before it runs, and it skips rather than fails when the URLs are absent, so CI stays green.
  • The tall fixture publishes one masked region: everything whose value legitimately varies between runs lives in aside#volatile, and everything outside it must be pixel-identical.

Artifacts

  • file test/fixtures/capture/tall-motion-v1.html — Versioned tall fixture covering the published motion matrix
  • file scripts/publish-capture-fixtures.mjs — Hash-verified publication of the repository fixtures
  • file test/integration/browserless-capture.integration.test.ts — The real Browserless proof for VAL-CAPTURE-003 and VAL-CAPTURE-004

Superseded by D041.

D038 — The DOM manifest is bounded twice: in-page for response size, server-side for what is persisted

2026-09-09 · build · accepted · Agent decided alone

Problem

The manifest is produced by code running in a remote sandbox against a page Pinata does not control, then crosses a network. Trusting the in-page pass alone would let a hostile or drifting page write unbounded, markup-bearing, or privacy-compromising data straight into Turso; re-fetching metadata in a second provider call would break the same-layout correlation with the screenshot.

Decision

The single Function API execution builds the manifest in-page with effective-visibility clipping (ancestor overflow, closed details/dialog, off-canvas, clip-path), a closed attribute allowlist, visible-text-only assembly, and deterministic vertical-stride truncation, and draws the layout nonce into the screenshot while describing it as a pinned manifest element. Server-side, result.ts enforces the exact-key schema with finite bounded numbers, then boundManifest() in src/lib/server/captures/manifest.ts re-sanitizes every string and rectangle, re-applies both caps (500 elements, 262,144 UTF-8 bytes), re-samples with the same vertical stride, adds the manifest-truncated warning when it had to drop anything, and execute.ts refuses a ready transition unless the nonce survives in the bounded manifest. POLICY_VERSION moved to 2026-09-08.7 with three new constants: MANIFEST_HINT_MAX_CHARS, MANIFEST_MAX_COMBINING_MARKS, MANIFEST_RECT_MAX_PX.

Alternatives considered

  • Trust the in-page bounds and persist what arrives — The response is untrusted input; a page that finds a gap in the sandbox cleaner would land hostile content in the database and later in the UI.
  • Fail the capture when the provider manifest exceeds a cap — The screenshot is perfectly good; dropping a whole capture over a trimmable metadata overflow makes a bounded problem fatal. Degrade-and-warn keeps the capture ready, which is what the published outcome catalog says.
  • Sample truncation by document order without vertical sorting — Document order is not visual order on pages with positioned or multi-column content; sorting by rectangle top makes top/middle/bottom coverage a property of what the user sees.

Rationale

Defense in depth with distinct jobs at each layer: the page-side pass keeps the response small and does the layout-aware visibility work only a browser can do, while the server-side pass is the authority on what is persisted and can degrade rather than fail. The nonce element pinned ahead of sampled candidates makes image/manifest correlation survive worst-case truncation. This is safe to decide unilaterally: it implements the assigned feature's published assertions inside the already-approved architecture and constants discipline.

Consequences

  • Every manifest string passes through two sanitizers; the page-side one must stay behaviorally in lockstep with sanitizeManifestString().
  • Any new manifest field is a five-file catalog change plus schema, in-page, and sanitizer updates.
  • A capture whose manifest loses its nonce element fails as browserless-provider rather than ready.

Artifacts

  • file src/lib/server/captures/manifest-source.ts — In-page visibility, sanitization, and deterministic sampling pass
  • file src/lib/server/captures/manifest.ts — Server-side bounding that gates what is persisted
  • file test/fixtures/capture/manifest-v1.html — Controlled sentinel fixture for real-provider exclusion proof

D039 — Track a known orphan object in its own bounded table, never by rewriting the terminal capture row

2026-09-09 · build · accepted · Agent decided alone

Problem

Turso and Blob are not transactional. A capture can upload a private object and then lose its finalization fence — the capture row is already terminal and immutable, so it can never reference the object, which makes the object an orphan. The object must be deleted, but the delete itself can fail. VAL-CAPTURE-009 requires a known orphan to be deleted or represented by bounded cleanup state, and VAL-CAPTURE-008 forbids rewriting a terminal row to carry that state.

Decision

A new capture_cleanups table records exactly one known orphan per internal blob pathname, with the uploading capture id for correlation, a bounded attempts counter, a retry deadline (the new CAPTURE_CLEANUP_WINDOW_MS = 3,600,000 ms), and a last-error label — no target URL, credential, or provider URL is ever persisted. execute.ts records a cleanup row when the fenced finalization's orphan delete fails, and reconcileCaptureCleanups() deletes pending orphans (an absent object completes cleanup), retries failures inside the window, and stops incrementing past the deadline while still deleting on sight. Cleanup state lives in its own table so terminal capture rows stay untouched. POLICY_VERSION moved to 2026-09-08.8.

Alternatives considered

  • Record the orphan on the capture row — The row is already terminal when the orphan exists; rewriting it to track cleanup violates the immutable-attempts invariant the whole feature rests on.
  • Best-effort delete with no tracking row — A failed delete would leave an untracked orphan with no record that it ever existed, which is exactly the false-clean state the assertion forbids.
  • Retry the orphan delete forever — An unbounded retry loop is unbounded work; the window bounds retry effort while the obligation to delete on sight never expires.

Rationale

The table makes the orphan a first-class, durable, queryable fact instead of a side effect, which is what lets a later cleanup pass prove no untracked orphan remains. Keeping it off the capture row preserves terminal immutability, and the attempts-plus-deadline shape satisfies the bounded-cleanup requirement. This is safe to decide unilaterally: it implements the assigned feature's published assertion inside the approved persistence architecture, using the existing migration tooling.

Consequences

  • capture_cleanups gains a committed migration (0002); the schema test table list and migration count were updated.
  • CAPTURE_CLEANUP_WINDOW_MS joins the versioned boundary catalog (five-file change) at POLICY_VERSION 2026-09-08.8.
  • A future cleanup pass must call reconcileCaptureCleanups; the rows are the durable backlog.

Artifacts

  • file src/lib/server/captures/cleanup.ts — Bounded orphan-cleanup record and reconcile pass
  • file src/lib/server/captures/execute.ts — Fenced finalization now records a cleanup row on a failed orphan delete
  • file test/server/capture-cleanup.test.ts — Bounded retry, deadline, and confirm-gone behavior

D040 — Publish capture fixtures to a dedicated public Vercel Blob store

2026-09-09 · validate · superseded · Human directed

Problem

The disposable fixture host behind D037 died: litterbox.catbox.moe began refusing every upload with HTTP 403 behind a BunkerWeb anti-bot, and the alternates were unusable (0x0.st disabled uploads, x0.at serves text/plain + nosniff so Chromium will not render it, filebin.net forces a redirect plus attachment disposition, paste.rs fails TLS from this machine, no tunnel client is installed). Every real-provider capture suite was blocked, so a durable fixture host needed a user decision.

Decision

Per the user's direction, publish the fixtures to a dedicated public-access Vercel Blob store. The store pinata-fixtures (store_6lu0gxibrNzwskvk) was created and connected to the pinata project's Development environment under the FIXTURE_BLOB prefix, keeping it separate from the private capture store.

Alternatives considered

  • A separate unprotected Vercel project serving the fixtures as static files — Declined at the time in favour of the Blob store; later proven to be the only option of the two that can serve renderable HTML, and adopted as D041.
  • Restore catbox.moe upload access — The 403 is IP/ASN-level anti-bot enforcement outside our control; a durable host was preferable to depending on it again.

Rationale

The user chose the Blob store from the options presented. On execution it proved platform-incapable: Vercel Blob force-serves every HTML-family content type with Content-Disposition: attachment, a documented anti-phishing measure ('This also prevents hosting HTML pages on Vercel Blob'). A content-type matrix probe (text/html, text/html;charset, application/xhtml+xml all attachment; only displayable types like text/plain inline) and a real Playwright Chromium navigation (page.goto aborted with 'Download is starting', the download event fired, the h1 never rendered) confirmed no upload-time override exists in @vercel/blob 2.8.0. Upload and readback otherwise worked: exact sha256 match and declared text/html.

Consequences

  • No repository files were changed for this attempt; the empty store and its Development-only FIXTURE_BLOB_READ_WRITE_TOKEN were rolled back under D041.
  • The private capture store pinata-captures and its BLOB_READ_WRITE_TOKEN were verified untouched throughout.
  • Any candidate fixture host must now be probed for attachment disposition on HTML and a real browser navigation before adoption — three host choices in a row failed only at serve time.

Provenance

Human: “Public Vercel Blob store (recommended)”

Artifacts

Superseded by D041.

D041 — Capture fixtures are served by a separate unprotected static Vercel project

2026-09-09 · validate · accepted · Human directed

Problem

The user's first directed durable host (D040, a public Vercel Blob store) was proven platform-incapable of serving renderable HTML — Blob forces Content-Disposition: attachment on every HTML-family content type, verified by a content-type matrix and a real Chromium navigation that downloaded instead of rendering. The real-provider fixture pipeline was still blocked and needed a re-decision.

Decision

Per the user's re-decision, the fixtures are served by a tiny separate Vercel project, pinata-fixtures, deploying test/fixtures/capture/ (echo-v1, tall-motion-v1, manifest-v1, links-v1) as static files with deployment protection disabled. npm run fixtures:publish ensures the project exists, disables its protection, deploys the exact repository bytes, and refuses to print a URL unless each fixture reads back as a direct 200 (no interstitial or SSO redirect), inline text/html with no attachment disposition, and a byte-exact sha256 match. The durable base URL is committed in test/fixtures/capture/host.json (public and non-secret), so fixture URLs no longer depend on a per-run host. The failed D040 store (store_6lu0gxibrNzwskvk) was deleted and .env.local re-verified to hold all required variable names afterwards. This also supersedes D037's disposable-per-run host mechanism; its fixture versioning, hash-verified readback, and environment handoff all carry forward.

Alternatives considered

  • Disable deployment protection on the main pinata project and host the fixtures there — It trades a real safety control on the product surface for test convenience, and puts test pages on the application's public surface. Protection on the main project was verified unchanged (ssoProtection all_except_custom_domains).
  • Keep searching disposable hosts — Three have now failed at serve time (catbox anti-bot 403, x0.at nosniff text/plain, filebin.net attachment redirect) and none are durable; every real-provider run would keep depending on a per-run upload.
  • Repurpose the public Blob store with non-HTML content types — Serving HTML as text/plain makes Chromium refuse to render it; the fixture must be a page a real browser navigates to.

Rationale

A separate unprotected project is the one option that satisfies every constraint at once: inline text/html (plain static file serving, no forced disposition), durable public HTTPS URLs reachable from Browserless's network, zero coupling to the main project's protection, and no fixture content in the private capture Blob store. The published content is inert versioned markup with no secrets and no application data, so an unprotected static host carries no meaningful exposure.

Consequences

  • Fixture URLs are durable: https://pinata-fixtures.vercel.app/<fixture>.html. The publish step is now an idempotent deploy plus verification instead of a per-run upload to an expiring host.
  • The fixture project hosts fixtures only; capture screenshots and all product data stay in the private Blob store, and no fixture content enters it.
  • The rule is now recorded for any future host candidate: probe for Content-Disposition: attachment on text/html and prove a real browser navigation renders the page before adoption.
  • vercel blob delete-store silently rewrites .env.local via an env pull; after deleting store_6lu0gxibrNzwskvk the local file was re-verified to contain exactly the required variable names (the stale FIXTURE_BLOB_READ_WRITE_TOKEN line was removed).

Provenance

Human: “Separate unprotected Vercel project”

Artifacts

  • file scripts/publish-capture-fixtures.mjs — Idempotent deploy plus verified readback against the durable fixture project
  • file test/fixtures/capture/host.json — Committed durable base URL and per-fixture byte/hash record
  • file test/fixture-host.test.mjs — Gate-time integrity check that the committed host record matches the fixtures

Supersedes D040.

D042 — Browserless concurrency is a durable two-slot lease table; the editor polls the hierarchy on a published backoff schedule

2026-09-09 · build · accepted · Agent decided alone

Problem

MAX_ACTIVE_CAPTURES was a published constant with no enforcement: dispatch admitted every pending attempt immediately, so two browsers or two application instances could overlap more than two Browserless jobs, and a quota-rejected attempt had no defined state. The editor also had no way to watch an attempt finish: creation redirected to a list that showed pending/capturing rows forever until a manual reload, and a naive fixed-interval poller could spin forever on abandoned work.

Decision

A durable capture_leases table (migration 0003) holds exactly MAX_ACTIVE_CAPTURES slots. A dispatch claims a slot with an atomic conditional upsert that only succeeds when the slot is free or expired, so the limit holds across clients and application instances without any in-process counter. A lease expires at the published stale age (STALE_CAPTURE_AGE_MS), so an abandoned attempt becomes reclaimable at exactly the instant its row computes stale; release is conditional on the capture id, so a late release from an abandoned worker cannot free a slot a newer attempt reclaimed. A quota-rejected attempt stays pending with the catalog's quota-exceeded outcome (429 plus bounded retry guidance), resumable by any later authorized client. The editor polls the hierarchy GET on the published schedule (2 s initial, doubling to a 10 s ceiling, 10 minute deadline) and stops as soon as every attempt is terminal or computed stale; polling is read-only and can never create an attempt. The outcome catalog drives the dispatch route end to end, and two real gaps found while proving it were closed: a throwing Blob put now maps to blob-failure, and a throwing orphan delete in the fenced path now records bounded cleanup state instead of escaping as a provider error. The fixture publish readback gained a bounded retry (6 attempts, 10 s apart) so post-deploy alias propagation lag cannot fail a publish.

Alternatives considered

  • In-process concurrency counter per server instance — It silently multiplies the limit by the instance count and vanishes on redeploy; the contract requires the limit across instances and resumability after redeployment.
  • Derive concurrency from capturing rows in the captures table — A capturing row outlives its worker (crash after the claim, before execution), and fencing already treats the row as the worker's claim; overloading it with slot accounting couples quota to finalization order and cannot express expiry independently of the row.
  • Server-sent events or websockets for progress — The hierarchy read is already the authorized, tested shape of progress; a second live channel adds a surface for a demo-scale need. Polling the existing GET with a bounded schedule carries zero new server code.

Rationale

Safe to decide unilaterally: the mission fixes max-2 concurrency as binding architecture, and a lease table is the smallest durable mechanism that enforces it across instances while sharing its expiry with the computed-stale boundary the state machine already publishes. The polling schedule reuses the existing authorized hierarchy read, so no new endpoint or permission shape was introduced.

Consequences

  • Every dispatch now takes the lease claim before admission; the dispatch route answers 429 with the quota-exceeded outcome and releases the lease only after execution finalizes the row.
  • capture_leases is run-state, not content: rows are deleted on release or reclaimed on expiry, and tests prove one attempt can never hold two slots.
  • Three polling constants (CAPTURE_POLL_INITIAL_INTERVAL_MS, CAPTURE_POLL_MAX_INTERVAL_MS, CAPTURE_POLL_DEADLINE_MS) join the boundary catalog under POLICY_VERSION 2026-09-08.9, with the five-file change applied (catalog, EVALS, ARCHITECTURE, drift test, version).
  • scripts/publish-capture-fixtures.mjs verifyReadback retries 6 times 10 s apart before failing a publish.
  • Real-provider proof: the integration suite now runs three concurrent dispatches against real Turso plus Browserless and asserts exactly two overlapping executions, a pending-and-resumable third attempt resumed through a second database handle, and zero remaining leases.

Artifacts

  • file src/lib/server/captures/leases.ts — Durable slot claim, conditional release, and live-slot count
  • file src/lib/capture-polling.ts — Published backoff/stop polling state machine consumed by the editor
  • file test/server/capture-outcomes.test.ts — Exact 18-row outcome catalog matrix plus route-driven cases and sentinel leak scans
  • file test/integration/browserless-capture.integration.test.ts — Real-provider overlap-timestamp and pending-resume proof

D043 — Remote-network safety is proven against the real provider with a two-page fixture split driven by the provider kill-switch map

2026-09-09 · build · accepted · Agent decided alone

Problem

The in-function request guard and server-side admission are proven by focused tests, but neither can prove what the provider network does when a public-shaped name resolves private a moment after admission, and a naive all-in-one attack fixture (18 probes against every private destination shape) was destroyed outright by the provider: Browserless returned HTTP 400 Target closed for the whole session, so no outcome at all was provable. The safety property had to be mapped empirically before it could be asserted.

Decision

Map the provider enforcement boundary with bounded instrumented runs, then encode the map as two version-pinned fixtures on the durable pinata-fixtures Vercel project. remote-network-v1 (expected ready) carries the survivable matrix: RFC1918-literal fetches, image, and frame that the in-function guard aborts (recorded as bounded blocked reasons), fetches to private-resolving names (static nip.io names plus the live-alternating rbndr.us name) and through the fixture host own 302 routes into private-resolving names, WebSocket handshakes the request guard cannot see, and a worker-internal fetch. Every probe cancels its own attempt on timeout (AbortController, socket close, worker terminate, frame removal) because a silently dropped private request otherwise pends forever and keeps the page network from ever going idle, which once stalled the capture until the total deadline. Private frame probes insert only after the window load event because a connected frame delays its parent load and a dropped destination would then stall navigation itself. remote-network-hard-v1 (expected bounded safe failure with zero artifacts) carries the session-fatal literals: loopback, link-local, metadata, and both IPv6-local forms, any one of which the provider answers by destroying the browser target. The live integration suite executes both against the real provider, scans the exact persisted manifest JSON and every decoded screenshot pixel for the runtime-assembled leak marker, asserts ordinary public subresources still load, and models the post-admission DNS-change window with seeded capturing rows.

Alternatives considered

  • One fixture covering every destination shape in a single page — The provider destroys the session when a page attempts a literal loopback, link-local, metadata, or IPv6-local request, even when the in-function guard aborts it first; one mixed page can therefore never produce a ready artifact, and the survivable vectors would lose their proof.
  • Mock the provider enforcement in unit tests only — The entire risk lives in the provider network layer after admission; a mock asserts the shape of our own assumptions and could never have discovered the kill-switch, the silent-drop behavior for private-resolving names, or the WebSocket interception gap.
  • Let hanging private probes burn the per-phase timeouts — A silently dropped request pends for the whole capture: waitForNetworkIdle never idles and the attempt dies at the total deadline with no artifact. Probe self-cancellation turns the same attack into a five-second ready capture, which is what makes the positive sentinel scan meaningful.

Rationale

Safe to decide unilaterally: the mission contract (VAL-CAPTURE-013) fixes the property to prove and the durable fixture host as the publication mechanism; only the empirical provider behavior was unknown, and discovering it required exactly the bounded debug runs performed. The two-page split is the smallest fixture design that matches the observed enforcement boundary without weakening any probe.

Consequences

  • test/fixtures/capture/remote-network-v1.html and remote-network-hard-v1.html are version-pinned and immutable once published; pixel-v1.png joins the host as the public-subresource fidelity proof, and the fixture host gains two 302 redirect routes (/redirect-v1/meta, /redirect-v1/loopback) into private-resolving names.
  • Provider behavior map of record (2026-09-09): literal loopback, link-local, metadata, and IPv6-local requests destroy the session; RFC1918 literals are guard-aborted and recorded; private-resolving names are fast-refused or silently dropped at the network layer; WebSocket handshakes are invisible to request interception; the DNS alternation of 7f000001.08080808.rbndr.us was observed live (6 public, 2 non-public verdicts in the recorded run).
  • The integration suite treats a ready capture of any private-resolving target as a loud test failure, never as a blessed outcome.
  • Fixture probes must always self-cancel: any future version that omits cancellation reintroduces the total-deadline stall.

Artifacts

  • file test/fixtures/capture/remote-network-v1.html — Survivable 14-probe matrix with self-canceling probes and runtime-assembled leak marker
  • file test/fixtures/capture/remote-network-hard-v1.html — Session-fatal literal destinations; expected outcome is a bounded safe failure with zero artifacts
  • file test/integration/browserless-network-safety.integration.test.ts — Real-provider proof: sentinel scans of manifest and pixels, live DNS-alternation evidence, post-admission seeded attempts, hard-fixture failure bound

D044 — Private screenshot delivery is one non-redirecting route that reauthorizes every request and revalidates bytes before serving

2026-09-09 · build · accepted · Agent decided alone

Problem

Captures are stored as private Vercel Blob objects, but the contract (VAL-CAPTURE-010, VAL-CAPTURE-014) requires that bytes reach a browser only through an authorized application route: provider URLs and pathnames may never be disclosed, a warmed cache or old URL must never replay an image after authority ends, and delivery must return exactly the bytes the capture validated. The range/conditional semantics, the cache policy, and the integrity posture all constrain the founder-capability work in milestone 2, so they needed to be fixed explicitly rather than improvised per caller.

Decision

Serve screenshots only from GET/HEAD /api/captures/<captureId>/asset. The route verifies the live editor session on every request — including ones answered 304, 206, or by HEAD — then resolves the capture through the project hierarchy: only a ready capture of a live project with a complete, policy-shaped storage record resolves; nonexistent, non-ready, deleted-project, and integrity-failed cases share one bounded generic 404, and anonymous, expired, or tampered sessions share one 401. Range support is exactly one bytes=<start>-<end?> range (206 with Content-Range); suffix, multi-range, reversed, and non-numeric ranges are 400, and an unsatisfiable range is 416 with the published length — all settled from the persisted record before any provider read. Conditionals (If-None-Match, weak forms and *, then If-Modified-Since) are answered from the persisted SHA-256 without fetching the object. When bytes are served they are revalidated against the persisted content type, byte length, and SHA-256, and the strong ETag is that SHA-256. Every response — success, denial, and 405 alike — carries Cache-Control: private, no-store, max-age=0, X-Content-Type-Options: nosniff, Vary: Cookie, and Accept-Ranges: bytes, published as ASSET_CACHE_CONTROL/ASSET_VARY/ASSET_RANGE_UNIT in the boundary catalog (POLICY_VERSION 2026-09-09.1, five-file change).

Alternatives considered

  • Redirect authorized requests to a short-lived signed Blob URL — A signed URL is a bearer capability that escapes the application's authority: once issued it works for anyone holding it until expiry, it discloses the provider hostname and pathname, and it cannot be revoked on logout or rotation. Proxying costs one extra read through the server and keeps every byte behind live authorization.
  • Serve full bodies only and reject all range/conditional requests — The contract names documented GET/HEAD/range/conditional behavior, and long captures are exactly where resume and revalidation matter; ignoring If-None-Match would also waste the one cheap integrity anchor (the persisted SHA-256) that lets a 304 cost no provider read.
  • Trust the persisted storage record and skip re-hashing fetched bytes — The hash check is the only proof that the object now in the store is the object the capture validated; without it a corrupted or substituted object would be served with a confident ETag. The cost is one SHA-256 over at most 8 MiB per fetch, negligible against the provider read itself.

Rationale

Safe to decide unilaterally: the mission architecture already fixes private Blob storage behind authorized application routes, and the validation contract fixes the method/range/conditional/cache matrix and the exact-bytes requirement; this record pins the interpretation (single explicit-start ranges, hash-as-ETag, fail-closed integrity, deny-all-responses-cacheable-never) inside that approved direction.

Consequences

  • The founder-capability worker (VAL-CAPTURE-010) extends this same route with capability-session authorization rather than creating a second delivery path; rotation/revocation denial then falls out of reauthorizing every request.
  • deliverCaptureAsset is the only module that may turn a capture id into bytes; its resolve-then-revalidate order is the denial-equality invariant the cross-surface hardening feature will scan.
  • The ETag of a capture image is publicly its SHA-256; clients may cache-validate but never cache-store.
  • Three asset constants join the boundary catalog under POLICY_VERSION 2026-09-09.1 with the five-file change (catalog, EVALS, ARCHITECTURE, drift test, version).

Artifacts

  • file src/lib/server/captures/asset.ts — Resolution, range/conditional semantics, and fail-closed integrity revalidation for private delivery
  • file app/api/captures/[captureId]/asset/route.ts — GET/HEAD route: live session verification on every request, safety headers on every response
  • file test/server/capture-asset.test.ts — 28-case route boundary matrix: exact bytes/headers, range and conditional semantics, generic byte-free denials
  • file test/integration/blob-asset.integration.test.ts — Real private Blob proof: metadata/hash match, unauthenticated provider denial, exact authorized delivery, verified cleanup
  • file e2e/asset.spec.ts — Production-build proof over HTTP plus browser cache/logout/history: network log shows 200, 200, 401, 401

D045 — Editor project entry is one explicit four-state list machine with a single-flight retry, and e2e run cleanup lives in teardown

2026-09-09 · build · accepted · Agent decided alone

Problem

VAL-AUTH-008/009 require the authenticated project list to have distinct, announced loading, empty, populated, and failure states, with a failure retry that cannot multiply reads and an empty state offering exactly one create action. Separately, two validation sessions leaked run-scoped rows into the real Turso database because run cleanup lived in a trailing Playwright test, which an aborted or failed run never reaches.

Decision

Model the project list in EditorHome as one explicit state machine (loading / ready-empty / ready-populated / failed) rendered inside the named Projects region with aria-busy: loading is a role=status line, failure is a role=alert plus a single-flight Try again button that is disabled while its one GET is in flight, and the single New project control lives inside the region so the empty state itself offers the primary action; a failed background read never unmounts or clears an open create form, and logout renders outside the list state entirely. In e2e, all run-scoped Turso deletion (captures, pages, projects, and idempotency keys matched by run id and by the run’s page ids, since capture-retry keys store page ids) runs in test.afterAll with absence assertions inside the hook, never in a trailing test.

Alternatives considered

  • Keep cleanup as a final verification test — An aborted or failed run skips trailing tests, and this already leaked two projects and a dozen idempotency keys into the real database; teardown hooks run on abort, trailing tests do not.
  • Let the retry button re-click freely and dedupe server-side — The list read is idempotent, but the contract asks for one request per retry intent; disabling the in-flight control makes the single-read guarantee a client-side fact provable by request counting instead of an inference.
  • Route the empty state to a separate /projects/new page — VAL-AUTH-009 requires entry without manual route entry and no resubmission on Back/Forward; an in-place form on the single landing route satisfies both with no history entries at all.

Rationale

Safe to decide unilaterally: the state-machine shapes follow the experience worker’s scoped-deterministic-states rules, and teardown-based cleanup is explicit orchestrator guidance recorded in the mission AGENTS.md after the 2026-09-09 leak; both are mechanical choices inside the approved architecture that this record pins for future editor-surface work.

Consequences

  • Any new editor list state must join the same discriminated union in editor-home.tsx rather than adding a sibling flag.
  • Every Playwright spec that writes run-scoped rows must clean them in afterAll/global teardown with absence verified in the hook; projects.spec.ts is the reference pattern, including page-id-keyed idempotency rows.
  • The orphan project valrun-mtta24to-4a6e7ff9-proj and twelve orphaned idempotency keys from earlier aborted runs were deleted from real Turso and their absence re-queried (projects, pages, captures, keys all zero).

Artifacts

  • file src/components/editor-home.tsx — Four-state project list with single-flight retry and in-region create control
  • file test/editor-home-entry.test.tsx — Component proof: distinct announced states, one-read retry, form retention across background failure
  • file e2e/projects.spec.ts — Failure/retry request-count e2e and teardown-based run-scoped cleanup verified absent in afterAll

D046 — Markdown list loops absorb wrapped continuation lines, so a blank line is the only way to end a list

2026-09-09 · build · accepted · Agent decided alone

Problem

Milestone-1 scrutiny found the hand-rolled requirements renderer consuming only list-marker lines: indented continuation lines rendered as stray paragraphs (80 severed continuations across the four source docs) and every multi-line ordered item became its own single-item <ol>, so /reqs Functional requirements rendered as eight lists numbered 1. The defect shipped because the renderer had only ever been tested against synthetic single-line fixtures.

Decision

Both list loops in src/lib/markdown.ts now consume non-blank lines that do not start another block (fence, heading, blockquote, list marker, thematic break) into the current item, joining them with a single space like paragraph wrapping. A blank line is the only terminator of a list; a non-blank line directly after a list item is absorbed into that item, so the four source documents must separate a following paragraph from a list with a blank line. The renderer is now tested against the real source documents: a structural assertion recomputes expected <ul>/<ol>/<li>/<p> counts from each source and the Functional requirements section is pinned to one ordered list of eight items.

Alternatives considered

  • Require continuation lines to be indented deeper than their marker (CommonMark-style) — The source docs wrap items at a fixed three-space indent under a one-character marker; a strict indent rule would still sever items and would force a rewrite of all four human-edited sources for no semantic gain.
  • Adopt a real Markdown package — D021/D022 keep the renderer zero-dependency and the approved dependency set fixed; continuation absorption is a ten-line change inside the existing safety contract.

Rationale

Safe to decide unilaterally: the behavior was mandated by the milestone-1 scrutiny finding assigned to this feature, the absorption rule matches how the four source documents are actually written (verified: no list is followed by a non-blank non-marker line and no continuation line is table-like), and the change stays inside the renderer's existing allow-listed, raw-HTML-disabled contract.

Consequences

  • Authors of docs/REQUIREMENTS.md, docs/ARCHITECTURE.md, docs/MILESTONES.md, and docs/EVALS.md must end every list with a blank line; a non-blank line directly under a list item becomes part of that item.
  • Nested lists remain unsupported: an indented marker line starts a new sibling item, matching the flat-list usage in all four sources.
  • test/requirements-sources.test.ts now guards the real documents structurally, so any future renderer change that severs continuations fails the gate.

Artifacts

  • file src/lib/markdown.ts — List loops consume wrapped continuation lines into the current item
  • file test/requirements-markdown.test.ts — Wrapped multi-line ordered and unordered item fixtures that fail on the old renderer
  • file test/requirements-sources.test.ts — Real-source-doc list-structure assertion proving zero severed continuations across all four docs

D047 — Darken the brand accent token to WCAG AA, match the Markdown external-host treatment on decision artifact links, and repair heading order and landmark uniqueness on /reqs/decisions

2026-09-09 · validate · accepted · Agent decided alone

Problem

User-testing round 1 found four blocking defects on the /reqs surface. The three external decision-artifact links rendered by src/components/decisions-catalog.tsx showed no visible HTTPS destination, unlike links emitted by the Markdown renderer. The brand red #d1495b failed WCAG AA as link text on the cream background (4.06:1) and as the badge fill behind white nav text (4.29:1); both need 4.5:1. Decision card titles were h3 directly under the page h1 (a heading-order skip), and the repeated Artifacts/Provenance/Alternatives/Consequences <section> landmarks carried identical aria-labels across every card (landmark-unique).

Decision

Darken the single global --accent token in app/globals.css from #d1495b to #c43448, which keeps the warm brand red hue while reaching 4.98:1 on --bg and 5.25:1 with --surface text, fixing the links, wordmark, home h1, error text, and the current-page nav badge in one move because every surface reads the same token. In src/components/decisions-catalog.tsx, artifact links now append the same visible (host) suffix the Markdown renderer emits, computed with new URL(url).host; card titles become h2 with in-card section labels as h3 so no heading level is skipped under the page h1; and the repeated region landmarks are scoped per decision (for example "Artifacts for D002"). The new ratios and structure are locked by test/visual-tokens.test.ts and test/requirements-decisions.test.tsx.

Alternatives considered

  • Introduce a separate darker link color and keep #d1495b for decorative uses — Two brand reds would drift apart and invite the next contrast regression; one token that satisfies every use keeps the palette honest and the gate enforceable.
  • Darken only to the 4.5:1 boundary (#c94054) — A ratio at the exact boundary leaves no margin for rounding differences between axe and the test's luminance math; #c43448 lands at 4.98:1 with room to spare.
  • Remove the aria-labels from the repeated sections so they stop being landmarks — The sections are genuine navigable regions on a 46-record page; scoping their labels per decision preserves the navigation value instead of flattening it.

Rationale

Safe to decide unilaterally: the violations were found by user testing against the already-approved WCAG AA and safe-rendering requirements (VAL-REQS-004 and VAL-REQS-006), so this is a correctness fix inside an approved direction, not a product choice. The only open parameter was the exact darker hex, which is constrained by the 4.5:1 floor and reversible by editing one token.

Consequences

  • --accent must never be lightened below 4.5:1 against --bg and --surface; test/visual-tokens.test.ts fails the gate if it drifts.
  • Every surface that reads --accent (links, landing h1, wordmark, field/capture errors, nav badge, focus outlines, blockquote borders) darkens together; the focus outline and decorative borders gain contrast as a side effect.
  • Decision card sub-section headings render at the same visual size as before, but the markup now descends h1 -> h2 -> h3 with no skips.
  • Future decision fields rendered as repeated <section> regions must carry per-decision labels to keep landmark-unique clean.

Artifacts

  • file app/globals.css — Single darkened --accent token with the AA floor documented at the point of definition
  • file src/components/decisions-catalog.tsx — External-host suffix on artifact links, h2/h3 heading order, per-decision landmark labels
  • file test/visual-tokens.test.ts — WCAG luminance checks locking --accent at >=4.5:1 on both backgrounds and as badge fill
  • file test/requirements-decisions.test.tsx — Host-suffix, heading-order, and landmark-uniqueness assertions against the real decision log

D048 — Make scrollable /reqs regions keyboard-focusable named groups and codify the axe sweep at desktop and 390px with @axe-core/playwright

2026-09-09 · validate · accepted · Agent decided alone

Problem

User-testing round 2 found the last blocking VAL-REQS-006 defect: at 390 CSS px, axe reported scrollable-region-focusable [serious, WCAG 2.1.1] on /reqs/architecture (the horizontally scrolling <pre> ASCII diagram) and /reqs/evals (a wide .table-scroll table). Keyboard-only users could not scroll these regions because they were not focusable. Round 1 had missed this because the axe sweep ran at desktop width only, so the narrow-viewport sweep also had to become a permanent, in-repo regression test rather than a manual validator step.

Decision

In src/lib/markdown.ts, fenced-code blocks now render as <pre tabindex="0" role="group" aria-label="Code sample"> and wide tables render inside <div class="table-scroll" tabindex="0" role="group" aria-label="Data table, scroll horizontally to view all columns">, so both potentially overflowing containers are in the tab order and carry an accessible name. The axe sweep is codified in e2e/requirements-a11y.spec.ts using @axe-core/playwright 4.13.0 (added to the approved dev-dependency allowlist in scripts/lib/approved-deps.mjs): all five /reqs routes are swept with wcag2a/wcag2aa tags at both 1440px and 390px and must show zero serious-or-critical violations, and the two known scrolling regions must prove real keyboard operability at 390px by receiving Tab focus and moving scrollLeft with ArrowRight.

Alternatives considered

  • Make the regions non-overflowing at narrow widths instead of focusable — The ASCII architecture diagram and the boundary-catalog tables are intrinsically wide; wrapping or shrinking them would mangle the diagram's alignment and truncate eval data. Focusable scroll regions are the WAI/Deque-recommended pattern for exactly this case.
  • Add aria-label without a role — aria-label on a plain <pre> or <div> is a prohibited attribute (axe aria-prohibited-attr); naming the regions requires a role that supports naming.
  • Use role="region" instead of role="group" — Named regions are landmarks; several code samples per page would create duplicate-landmark noise for screen-reader users. role="group" supplies the accessible name without landmark semantics, matching the Deque guidance for scrollable code examples.
  • Keep the axe sweep manual via agent-browser a11y instead of adding a dependency — Round 1 proved a manual, validator-only sweep silently narrows in scope (desktop-only); an in-repo Playwright spec runs on every npm run validate locally and in CI, so the regression cannot escape again. @axe-core/playwright is the standard thin wrapper over the same axe-core 4.13 the validators already use, pinned exactly, dev-only.

Rationale

Safe to decide unilaterally: the feature assignment from user-testing round 2 directed both the focusable-scroll-region fix and the in-suite narrow axe sweep, so this implements an approved correction rather than a product choice. The only open parameters were the ARIA role/label wording and the specific axe package, both constrained by WCAG 2.1.1 and the existing validator tooling, and both reversible in one module.

Consequences

  • Any future renderer or component that emits a potentially overflowing container on a public page must give it tabindex, a naming-capable role, and an accessible name, or the e2e sweep fails the gate.
  • @axe-core/playwright joins the approved dev-dependency set; the a11y sweep now runs in CI on every validate, at desktop and 390px.
  • The renderer contract comment in src/lib/markdown.ts now documents the focusable-named-scroll-region guarantee alongside the escaping and link-safety guarantees.
  • The keyboard-scroll e2e locates the overflowing instance of each region by measuring scrollWidth > clientWidth, so adding more (narrower) tables or code blocks cannot produce a false target.

Artifacts

  • file src/lib/markdown.ts — Fenced-code <pre> and .table-scroll render as tabindex=0 role=group named scroll regions
  • file e2e/requirements-a11y.spec.ts — axe wcag2a/2aa sweep at desktop and 390px on all five /reqs routes plus real keyboard-scroll proof
  • file test/requirements-markdown.test.ts — Renderer contract tests pinning the focusable, named scroll-region markup
  • file scripts/lib/approved-deps.mjs — Approved dev-dependency allowlist extended with @axe-core/playwright

D049 — Drive pending capture dispatch from the editor client, bounded by the durable lease cap and re-driven by the polling loop

2026-09-09 · build · accepted · Agent decided alone

Problem

User-testing round 1 found the capture-driver gap: project creation commits two pending attempts per page, but nothing ever dispatched them, so every capture sat in the Queued state forever unless someone hand-called POST /api/captures/:id/dispatch. The server deliberately schedules nothing itself — the durable two-slot lease table is the only concurrency authority — so the missing piece was a client that turns committed pending rows into dispatch requests, including after a reload or a return to the editor with work still pending.

Decision

The editor home now runs a dispatch driver next to the existing capture-progress poller, with the policy kept pure in src/lib/capture-dispatch.ts. Every hierarchy read that shows pending attempts (the initial load, the post-create re-read, a retry's re-read, or a poll tick) feeds pendingDispatchTargets, which lists pending attempts in deterministic project/page/device order, and nextDispatchBatch, which keeps at most MAX_ACTIVE_CAPTURES dispatches in flight from this client and skips attempts already in flight or inside their re-drive delay. A dispatch that settles (ready, or any attempt-consuming catalog outcome read from the response body) triggers one hierarchy re-read so the workspace surfaces the outcome and the freed slot drives the next pending attempt. A quota-exceeded 429 leaves the attempt pending and defers it for CAPTURE_DISPATCH_REDRIVE_DELAY_MS (defined as the published initial poll interval, not a new constant), so the polling loop is what re-drives it once a slot has had time to free; a 409 conflict or an untrustworthy answer defers the same way, which makes even a stale-read race a bounded one-attempt-per-poll-tick retry rather than a storm. No new endpoint exists, the hierarchy GET stays read-only, and provider execution still happens inside the dispatch request.

Alternatives considered

  • Dispatch from the server immediately after project creation commits — A server-side scheduler would need its own re-drive mechanism for quota-held and abandoned work, duplicating the lease reconciliation that already exists, and would create provider jobs with no client attached to observe them. The architecture had already assigned scheduling to the authorized client (at most two in parallel); the gap was that the client never did it.
  • Add a claim endpoint or a queue table the dispatch route polls — A second surface that finalizes attempts it did not admit violates the one-request dispatch invariant (admit, claim, execute, finalize in one request, D035) and adds a background-job failure mode. The durable pending rows already are the queue.
  • Re-dispatch quota-held attempts immediately on every hierarchy read — When another client or instance holds both slots, an immediate re-drive on every read is a hammer loop against the 429 fence. Deferring one initial poll interval bounds the re-drive to the published polling cadence, which is already backed off and deadline-capped.

Rationale

Safe to decide unilaterally: the architecture document already states the client schedules captures at most two in parallel, and the feature assignment (from the user-testing round 1 finding) directed closing exactly this gap, so the remaining choices — deterministic target order, an in-flight guard, and a deferral window equal to the initial poll interval — are mechanical and reversible inside src/lib/capture-dispatch.ts.

Consequences

  • Any open editor session drives every pending attempt it can see; captures no longer require a manual dispatch call, and a reload with pending work resumes driving automatically.
  • The editor projects e2e must stub the dispatch route with the quota outcome to stay hermetic — an open editor page with pending attempts now really dispatches against the real provider otherwise.
  • Dispatch answers are classified by the attempt-consuming catalog code in the body (settled) versus quota-exceeded/conflict/other (defer), so a terminal outcome can never be re-driven and a held attempt is always re-driven later.
  • The re-drive delay rides on CAPTURE_POLL_INITIAL_INTERVAL_MS; changing polling cadence changes re-drive cadence with it.

Artifacts

  • file src/lib/capture-dispatch.ts — Pure driver policy (targets, capped batching, re-drive deferral) plus the scoped-route dispatch helper
  • file src/components/editor-home.tsx — Driver effect wired next to the polling effect on every ready hierarchy read
  • file test/editor-home-dispatch.test.tsx — Wired driver proof: cap respected, quota re-drive, terminal outcomes never loop, conflicts never storm
  • file e2e/capture-driver.spec.ts — Real-provider proof: four attempts reach ready with zero manual dispatch calls and at most two in flight

D050 — Trim milestone 1: defer first Vercel deployment and the variant/retry integration matrix to milestone 2

2026-09-08 · build · accepted · Human directed

Problem

Milestone 1's critical path ran through its two longest-lead items before the user could ever see the product: a first Vercel production deployment carrying the real Chickpea project, and a standalone variant/retry integration matrix feature. Both were validation surfaces, not new product behavior — the underlying variant-isolation, stabilization, no-crawl, and partial-failure assertions were already covered by the surviving capture features — and waiting on them delayed the first live headed-browser checkpoint.

Decision

Per the user's scope trim, milestone 1 no longer includes a Vercel deployment or the standalone variant/retry integration feature. The capture-variant-partial-retry-integration and production-chickpea-capture-and-deployment features are cancelled with their assertions re-homed into the surviving capture features (no coverage lost). Milestone 1 validates the capture-and-organize slice locally as a production build on 127.0.0.1:3100, including live captures of Chickpea's root and its explicit /pricing, /about, and /privacy URL array. The first Vercel production deployment and the real production Chickpea project move to milestone 2 (real-chickpea-pin-and-founder-review), where deployment-backed assertions are re-verified against the real deployment.

Alternatives considered

  • Keep the original milestone 1 scope — The user explicitly asked to trim scope to shorten the critical path; the deployment and the integration matrix were the two items standing between green automated validators and the first human checkpoint.
  • Drop the re-homed assertions entirely with the cancelled features — Rejected by the trim itself: the user asked to remove the deployment and the matrix feature, not the behavior coverage. Variant isolation, stabilization, no-crawl, and partial-failure assertions moved into the surviving capture features so nothing became untested.

Rationale

The user directed both the trim and its contents, so the decision is recorded as user-directed with the verbatim request. The trim is coverage-neutral by construction — every assertion from the cancelled features was re-homed — and it is reversible: milestone 2 reinstates the deployment and the production Chickpea project as its own validation surface.

Consequences

  • Milestone 1 has no Vercel deployment: 'production/deployed' contract clauses are satisfied by the local production build (npm run start on 127.0.0.1:3100) with the substitution recorded per assertion, and must be re-verified against the real deployment in milestones 2 and 3.
  • docs/MILESTONES.md milestone 1 no longer promises a Vercel deployment or the production Chickpea validation; both are milestone 2 bullets.
  • The first live headed-browser checkpoint runs against the local production build instead of a public deployment.
  • Capture test fixtures are unaffected: they remain on the separate unprotected pinata-fixtures project (D041), and the main project's deployment protection stays on.

Provenance

Human: “"Trim scope to shorten critical path" — "remove Chickpea deployment, and variant/retry integraiton matrix"”

Artifacts

  • file docs/MILESTONES.md — Milestone 1 bullets corrected: no deployment, local-only Chickpea validation; milestone 2 now owns the first production deployment and the real Chickpea project

D051 — Materially descope the post-milestone-1 roadmap: keep pins, pin comments, landing page, first deployment, short pins session, and closeout; punt everything else

2026-09-09 · validate · accepted · Human directed

Problem

After the milestone-1 capture checkpoint, the remaining roadmap (founder capability links and read/reply surface, append-only threads, rich marks, the warm visual system, performance and one-minute-demo work, milestone-3 accessibility, production hardening, editor session-lifecycle hardening, cross-surface auth/secret hardening, and the final production acceptance session) was larger than the user's remaining budget. The user wanted to validate only the two things the product exists for: the screenshot captures and pinned-annotation commenting.

Decision

Per the user's direction, the mission is materially descoped. In scope: the editor canvas with pins and pin comments, nearby-DOM metadata on pins, the branded landing page, the first Vercel production deployment with production Chickpea captures, a short headed pins session with the user, and final docs closeout. Punted for later revisit: founder capability links and the founder read/reply surface, append-only two-way threads, rich marks (rectangles, circles, arrows), the warm-visual-system milestone, performance/one-minute-demo, milestone-3 accessibility features, production hardening (partial-failure drills, redeployment continuity, capability rotation, dogfood project), editor session-lifecycle hardening, cross-surface auth/secret hardening, and the final production acceptance session (replaced by the short pins session).

Alternatives considered

  • Continue with the full milestone plan — The user has a strict budget and explicitly directed the descope; the punted items are the product vision, not current work.
  • Cut scope silently without a record — The descope forecloses whole milestone surfaces and changes what 'done' means for validation; it must be auditable.

Rationale

The user directed the descope verbatim after the milestone-1 checkpoint, so this is recorded as user-directed. The kept slice (captures plus pinned comments) is exactly what the user said they want to validate; everything else remains documented as the product vision for later revisit.

Consequences

  • Validation scope shrinks to capture, pins/comments, landing page, deployment, a short pins session, and closeout docs; founder, thread, rich-mark, and hardening assertions are out of the executable contract.
  • Known weaknesses in punted areas (in-memory logout revocation across serverless instances, login-throttle test-safety override, the /reqs axe 'incomplete' node) are documented at closeout, not fixed.
  • The mission documents note that architecture sections describing founder/capability/thread/rich-mark scope describe the vision, not current work.

Provenance

Human: “after we do this test, let's MATERIALLY descope the rest of the project - punting major milestones for later. I have a strict budget I need to manage, and I just really want to validate the data capture (teh screen shots) and the abilit to comment with pinned annotations. We can revisit the rest of the milestones thereafter.”

D052 — Add a temporary local-only editor auth bypass flag (PINATA_AUTH_DISABLED), default off, never in .env.local or any deployment

2026-09-09 · build · accepted · Human directed

Problem

For live local checkpoint sessions the user does not want to sign in through the editor password prompt every time; auth is deliberately low priority right now. But the real auth posture (anonymous denial, login, throttling) must keep being proven by the validation gate and validators, and production must keep auth.

Decision

Add PINATA_AUTH_DISABLED as a server-only environment flag, default OFF. When set to exactly '1' (inline on the server command line, e.g. PINATA_AUTH_DISABLED=1 npm run start), server-side session verification treats every request as an authenticated editor with a synthetic session: the landing page renders the editor workspace directly and all editor APIs authorize, with the double-submit CSRF proof skipped because no real session cookie exists (route-level same-origin checks still apply). When unset, behavior is byte-identical to before, and the login route keeps working in both modes. The flag is never a NEXT_PUBLIC_* variable, never read from client code, never added to .env.local (so the gate and validators keep proving real auth), and never set in any Vercel environment.

Alternatives considered

  • Remove or comment out the password check in the auth code — That would change the committed default posture, break the gate's anonymous-denial and login coverage, and risk shipping disabled auth to production. A default-off flag keeps the real behavior the committed default.
  • Put the flag in .env.local for convenience — The validation gate and all validators run with .env.local present; the flag living there would silently disable auth in every validation run and destroy the evidence that the real posture works.

Rationale

The user directed the bypass verbatim and framed it as temporary ('for now'), so this is user-directed and scoped as narrowly as possible: one server-only check in the session guard and the landing route, default off, with focused tests proving both modes and source checks proving the flag never reaches client code or .env.local.

Consequences

  • Checkpoint and live user sessions start the server with PINATA_AUTH_DISABLED=1 inline; no sign-in prompt appears and all editor surfaces authorize anonymously on that local server only.
  • The validation gate runs without the flag and continues to prove anonymous denial, login, and throttling; .env.local must never contain the flag.
  • Production keeps auth: the flag must never be set in any Vercel environment, and the bypass is expected to be removed or revisited when auth becomes a priority again.
  • Known weakness while enabled locally: any process able to reach 127.0.0.1:3100 on the user's machine has editor authority; acceptable only because the flag is local, inline, and temporary.

Provenance

Human: “remove the password / comment it out for now. i just want to use it. auth is low priority.”

Artifacts

  • file src/lib/server/auth/bypass.ts — The server-only flag reader: true only for the exact value '1', default off
  • file test/server/auth-bypass.test.ts — Focused tests for both modes plus source checks that the flag never reaches client code or .env.local

D053 — Correct the checkpoint capture target: the seeded and demonstrated Chickpea is https://chickpea.co, not chickpea.vercel.app

2026-09-10 · validate · accepted · Agent decided alone

Problem

The orchestrator's reopened milestone-1 checkpoint feature text named https://chickpea.vercel.app as the pre-seed capture target. That host is an unrelated third-party chickpea-exporter template whose /pricing, /about, and /privacy all return 404, so three of its four pages cannot produce meaningful captures at all. The mission's actual Chickpea product — the target used by every mission document and every prior capture validator — is https://chickpea.co ("Chickpea: AI teammates in Slack"), with all four pages live.

Decision

Seed and demonstrate https://chickpea.co (root plus /pricing, /about, /privacy) as the milestone-1 checkpoint project, and record this correction so the feature-text discrepancy stays auditable rather than silently resolved. The user confirmed the chickpea.co target during the 2026-09-09 checkpoint session.

Alternatives considered

  • Seed chickpea.vercel.app exactly as the feature text named it — That host is an unrelated site and three of its four named pages 404; seeding it would have produced a demo of the wrong product and mostly-failed captures, contradicting every mission document and prior validator that used chickpea.co.
  • Block the checkpoint until the user confirmed the target — The mission's canonical target was unambiguous from the accumulated evidence, and the worker flagged the discrepancy prominently in its Phase A handoff for confirmation; proceeding kept the user's session on schedule and the confirmation arrived in session on 2026-09-09.

Rationale

Safe to decide unilaterally: the choice was a factual correction to match the target the mission had always used, not a product-direction choice, and it was flagged to the user and orchestrator in the Phase A handoff rather than silently swapped. The origin stays agent-autonomous because the in-session user confirmation (2026-09-09) was relayed without a preserved verbatim quote, and this log does not upgrade provenance without the evidence the taxonomy requires.

Consequences

  • The seeded Chickpea project (publicId 5h3lHTzGhMeg) and all milestone-1 checkpoint evidence refer to https://chickpea.co; any document still naming chickpea.vercel.app is wrong.
  • The same correction carries into milestone 2's production Chickpea project (D050): the production capture target is https://chickpea.co.
  • Future orchestrator-authored feature texts that name external targets should be checked against the library's verified-target notes before seeding.

Artifacts

D054 — Decide later whether an execution-time, provider-side unsafe-redirect should stay non-retryable

2026-09-10 · validate · superseded · Raised and deferred

Problem

During the user-directed pre-seed, the Chickpea Mobile-root capture attempt 1 failed with unsafe-redirect — an execution-time final-URL inconsistency inside the provider session, not reproducible via curl or Playwright mobile emulation, i.e. a transient provider-side flake rather than a genuinely unsafe target. The outcome catalog deliberately marks unsafe-redirect retryable:false (the catalog row exists so the admission layer cannot be used as an oracle), so the product offered no recovery path for what was effectively provider flake; the worker had to insert a pending attempt row directly in Turso and dispatch it through the real route to get the ready attempt 2. The failed attempt remains visible in the demo project tree.

Decision

Deferred. The question was raised to the user as a checkpoint talking point and the user never reacted to the failed Mobile-root attempt during the session, so it stays open: should an execution-time unsafe-redirect (raised after admission, inside the provider session) be distinguished from an admission-time unsafe target and made retryable, or should the catalog stay as is?

Alternatives considered

  • Make execution-time unsafe-redirect retryable now — That weakens a security-shaped catalog row without the user's call; the retryable:false mark is deliberate, and changing it is a product/policy choice, not a worker's.
  • Silently leave the failed attempt in the demo tree with no record — The failed row is visible in the seeded project the next milestone builds on; an unrecorded wart reads as a defect rather than a known, consciously postponed question.

Rationale

Raised by the checkpoint worker in its Phase A handoff and carried by the orchestrator as a session talking point; the user did not answer it during the 2026-09-09/10 session. Per the provenance taxonomy a consciously postponed item is user-deferred with status pending until answered, then superseded by the record that answers it.

Consequences

  • The seeded Chickpea project (kept per the 2026-09-10 orchestrator teardown amendment) continues to show one failed Mobile-root attempt 1 alongside ready attempt 2; canvas/pins features building on this seed should treat it as a known wart, not a regression.
  • The unsafe-redirect catalog row is unchanged: retryable:false, and the retry route keeps answering 409 for it.
  • When the user answers, a new decision flips this record to superseded with superseded_by naming the answer.

Provenance

Agent proposed: “A transient unsafe-redirect (execution-time final-URL inconsistency inside the Browserless mobile session, not reproducible via curl or Playwright mobile emulation) permanently failed mobile root attempt 1. The outcome catalog marks unsafe-redirect retryable:false, so the product offered no recovery path for what was effectively provider flake. (Raised by the checkpoint worker, carried to the user by the orchestrator as a session talking point, unanswered.)”

Superseded by D095.

D055 — The canvas opens every capture with the entire page in view (contain); width-fit and natural size remain named modes

2026-09-10 · build · superseded · Human directed

Problem

At the milestone-1 live checkpoint the human could not take in a tall capture (~9,000 px) without scrolling: the interim stage defaulted to a natural-size slice. The permanent React Flow canvas needed an initial camera, and a wrong default would enshrine the rejected behavior.

Decision

Every capture opens with the camera contain-fitted so the entire screenshot is inside the viewport with a small padding. Width-fit and natural-size stay reachable as named pressed-state modes; any pan/zoom gesture ends the mode's resize-follow until a mode is picked again. The camera is local UI state only: never persisted, never written to browser history, and issuing zero annotation mutations.

Alternatives considered

  • Open at natural size (1:1) inside a scrollable stage — Exactly the behavior the human rejected at the checkpoint: tall captures open on an arbitrary slice, not the page.
  • Open width-fit (full width, vertical overflow) — Still crops tall pages vertically on open; the direction was the entire page in view.
  • Persist the last camera per capture — D015 keeps domain state canonical in screenshot-natural pixels; a persisted viewport adds server state nobody asked for and complicates capture switching.

Rationale

Verbatim checkpoint instruction. Contain makes the first thing a reviewer sees the whole page, while the named modes keep precise inspection one click away.

Consequences

  • VAL-CANVAS-002 is worded around an entire-in-view initial camera; the canvas e2e measures all four corners inside the pane on open and after every selection change and hard reload.
  • Camera work (pan, wheel, pinch, zoom buttons, named modes) is guaranteed side-effect-free: zero mutation requests, zero history entries.
  • Zoom clamps at 8x; pan is unclamped so corner targets can center under the cursor, and the Entire-page mode is the one-click recovery when a user pans away.

Provenance

Human: “it should be presented such that the entire page is in view”

Artifacts

  • file src/components/capture-canvas.tsx — The controlled React Flow canvas implementing the three named camera modes.

Superseded by D145.

D056 — Pin geometry lives in a pure adapter (canonical tip plus zoom-aware hit box); annotation children carry no React Flow parent extent, and the drag grab offset is captured once per gesture

2026-09-10 · build · accepted · Agent decided alone

Problem

Bringing draft pins onto the canvas needed answers to three coupled questions: what the canonical geometry of a pin is (so persistence later stores one unambiguous fact), how a pin stays grabbable at every zoom without its anchor drifting, and who clamps drags at the frame edge. End-to-end testing then exposed that React Flow's parent-extent clamp rewrites emitted drag positions at the frame edge, which stranded the re-derived tip one grab offset inside the boundary (a tip dragged to the corner settled at natural (1.5, 3) instead of (0, 0)), and that React Flow re-emits the final drag position on pointer-up, which advanced a frame-clamped tip with no pointer movement at all.

Decision

The canonical pin fact is the tip in screenshot-natural CSS pixels. A pure adapter (src/lib/canvas/geometry.ts) owns all conversions: an inclusive document clamp, and a hit box whose on-screen edge never drops below the shared 24px minimum target (it grows in natural units as zoom deepens) while the tip stays recoverable exactly as box plus recorded offsets. Draft pin nodes are React Flow children of the screenshot frame WITHOUT extent: "parent" — the adapter is the single clamping authority. The drag grab offset is captured once at drag start and held for the whole gesture. Placement taps are disambiguated from drags by a 6px screen slop, and the draft is transient local UI state (one per plane, Escape clears, never persisted).

Alternatives considered

  • Keep extent: "parent" and compensate for the clamp in the drag handler — The extent clamp hides how far past the edge the pointer is, so no handler-side compensation can recover the canonical boundary tip; it can only guess. Verified by tracing emitted positions end-to-end.
  • Re-derive the grab offset from the current box on every position change — When the box is frame-clamped but the tip is not at the boundary, each re-derivation shifts the offsets, and React Flow's drag-end position re-emission then moves the tip with no pointer movement. Measured: the tip advanced on pointer-up.
  • Make the hit box a fixed natural-pixel size at every zoom — At 8x a fixed natural box shrinks far below the 24px shared minimum target and pins become ungrabbable exactly when precision matters; a fixed screen box would dwarf the document at overview zoom.
  • Persist the draft tip optimistically on placement — D051 descoped the roadmap to pins-first and the annotation API does not exist yet; persistence with numbering is the next milestone feature, and an optimistic write now would ship an unreviewed server contract.

Rationale

These are technical choices inside the already-approved canvas-and-pins direction (D051, D055): they change no user-visible scope, add no dependency or server contract, and were forced by measured end-to-end behavior, so they were safe to decide without surfacing. A single pure adapter keeps every screen-to-natural conversion in one tested boundary, which is what makes the one-natural-pixel inverse-transform contract provable at 1x and 8x.

Consequences

  • The next feature (pin persistence and numbering) stores exactly one natural-pixel point per pin; no box, zoom, or viewport data may enter the annotation record.
  • Annotation child nodes never use React Flow extent clamping; clamping tests live with the pure adapter and in the drag e2e (corner clamps land exactly on 0 and document edges).
  • Drag handlers must treat position changes as idempotent: React Flow may re-emit the same position at drag end.
  • Touch e2e requires a hasTouch context because d3-zoom ignores touch input when the browser reports no touch support.

Artifacts

  • file src/lib/canvas/geometry.ts — The pure natural-pixel coordinate adapter: clamps, hit boxes, grab-offset drag math.
  • file e2e/canvas-interactions.spec.ts — Mouse and CDP-touch e2e proving placement, grab-offset drag, corner clamps, and plane isolation at 1x and 8x.

D057 — Capture-driver e2e asserts the server fence (one claiming answer, fenced redrives) instead of a fixed per-attempt dispatch count

2026-09-10 · build · accepted · Agent decided alone

Problem

Under full-suite parallel load the capture-driver e2e flaked on two counting assertions that encoded wrong premises: an in-flight watermark measured at requestfinished (but the client caps by promise settlement, and a 2xx fetch resolves at response headers, so body-delivery lag read as a phantom cap violation), and a hard bound of two dispatches per attempt (but a hierarchy read that still shows a just-claimed row as pending legitimately re-drives the attempt, and the reload race can hide the claiming 2xx from the page entirely). The durable contract — the server fence claims an attempt at most once and every later dispatch is fenced — was never actually broken.

Decision

The test now counts a dispatch as in flight only until the server answers (response event), and per attempt asserts the real invariant: at most one 2xx claiming answer, every other answer fenced (409 conflict or 429 quota), and a small absolute dispatch bound (8) so "never an unbounded retry" stays explicit.

Alternatives considered

  • Keep the two-per-attempt count bound and re-run until green — The premise is false once hierarchy reads can lag the claim transition; the suite would stay flaky and every flake would erode trust in the gate.
  • Serialize the e2e suite to one worker — The two-worker bound is a deliberate contract choice; slowing the whole gate to protect one test's wrong premise trades away CI time for nothing learned.
  • Change the driver to never re-drive a conflict — A conflict can also mean another client claimed and then died; the deferred re-drive is how the attempt eventually gets driven again. The behavior is sound; the test premise was wrong.

Rationale

Test-only change that strengthens what is actually proven (the fence, the cap, the stand-down) while dropping a count bound whose premise read-after-write timing invalidates. Safe to decide unilaterally: no product code, route, or schema changes, and the new assertions fail loudly if the fence ever really breaks.

Consequences

  • Capture concurrency evidence now demonstrates fencing directly: redrives happen and are provably fenced, rather than assumed away.
  • Any future 5xx or unexpected dispatch answer fails the test loudly — the fence contract stays taut.

Artifacts

  • file e2e/capture-driver.spec.ts — Fence-based per-attempt assertions and the response-time in-flight watermark.

D058 — The canvas documents its own interactions on the page: pan, zoom, pin drop, comment, save, cancel, and opening a saved pin are all taught by persistent on-page instructions

2026-09-10 · build · accepted · Human directed

Problem

A first-time user opening a capture had no way to discover the pin workflow: the canvas supported pan, zoom, an explicit Place pin mode, drafts, and saving, but nothing on the page said so. The reviewer directive was explicit that the page itself must teach the workflow.

Decision

The workspace carries a persistent plain-language hint above the canvas that names every real interaction of this build: drag to pan, scroll or pinch to zoom, the camera buttons, Place pin mode with click/tap to drop a pin, writing a comment and pressing Save pin, Escape or Cancel discarding a draft, clicking a saved pin or its Pins-list entry to read its comment, and dragging a pin to move it. The empty pins panel repeats the discovery path, and the hint names no affordance that does not exist (no dead 'coming soon' features).

Alternatives considered

  • A one-time onboarding tooltip or tour — Dismissable UI fails the directive: the instructions must be persistent so the page stays self-documenting on every visit, and a tour is another dismissible surface to maintain.
  • Rely on the validation contract and README to document interactions — The directive was precisely that the page itself must teach the workflow; external documents are not the page.

Rationale

Direct execution of the verbatim request. The hint is intentionally exhaustive about the current build and nothing beyond it, so it can never drift into promising dead affordances.

Consequences

  • Any future canvas interaction must be added to the hint when it ships; the workspace component test asserts the hint's coverage phrases.
  • VAL-CANVAS-009 is satisfied by the page alone, with no external documentation dependency.

Provenance

Human: “add instrucitons on teh page itself to make it self-documented”

Artifacts

  • file src/components/project-workspace.tsx — The persistent workspace hint and the empty-pins discovery copy.

D059 — Pin persistence: server-assigned monotonic numbering inside the idempotency transaction, one create per saved draft, one revisioned write per drag, and authoritative reloads after failure

2026-09-10 · build · accepted · Agent decided alone

Problem

Persisting pins raised four coupled protocol questions: who assigns pin numbers (and how cancelled or failed drafts must never consume one), how a retried or double-submitted save stays exactly-once, how a drag move commits without write amplification, and what the UI shows when a write fails.

Decision

The server assigns numbers inside the create transaction as max(number)+1 over ALL rows of the capture including tombstones, backstopped by the (capture_id, number) unique index with a bounded collision retry, so deleted or failed numbers are never reused and drafts never reserve one. The annotations create route is idempotency-record-first: an exact replay returns the original record, a key reused with a different payload conflicts, and a failed validation consumes nothing. The client holds one idempotency key per draft intent, POSTs once per Save, and re-reads the capture's pin list after success rather than patching local state. A pin drag is local-only movement committed as exactly one revisioned PATCH at drag end (capture-bound, bounds-validated, revision-bumped); a failed move shows a bounded error and reloads the authoritative list so the pin snaps back. Camera, selection, and cancel paths issue zero writes.

Alternatives considered

  • Client-proposed numbers with server validation — Two clients placing concurrently would collide constantly and cancelled drafts would strand visible gaps; the server is the only authority that can be both monotonic and collision-safe.
  • Live PATCH on every drag frame — Write amplification with no durability benefit: intermediate frames are transient by definition, and the one-natural-pixel contract only concerns the final position.
  • Optimistically persist the new pin locally and reconcile later — An optimistic ghost has no server number; showing it would either fake a number or violate the monotonic-visible-numbers rule. The save latency on a local store is imperceptible.

Rationale

These are protocol choices inside the already-approved pins direction (D051) and the existing idempotency/revision pattern the capture and reply routes established; they change no user-visible scope and add no dependency, so they were safe to decide without surfacing. Reusing the established boundary order (same-origin, session+CSRF, content-type and byte cap, strict schema, durable write) keeps the new routes inside the reviewed envelope.

Consequences

  • Annotation records carry exactly one natural-pixel tip, a bounded body, an explicit null element snapshot, capture binding, and a revision; no React Flow state or camera data can enter persistence.
  • Move is the only pin mutation this build ships; deletion and edit remain future features with the tombstone scheme already in the schema.
  • A new boundary constant ANNOTATION_REQUEST_MAX_BYTES (16,384) joins the published catalog (POLICY_VERSION 2026-09-09.2).
  • The pins e2e intentionally leaves a numbered corner-fixture pin on the seeded Chickpea desktop capture as a regression-screenshot landmark; each full run adds at most three pins against the 200-per-capture quota.

Artifacts

  • file src/lib/server/annotations/pins.ts — createPinAtomically / movePin: transactional numbering, idempotency, and revision rules.
  • file app/api/captures/[captureId]/annotations/route.ts — List and create routes inside the established request boundary order.
  • file e2e/pins.spec.ts — Persist/reload/isolation, monotonic numbering with a cancelled draft, camera zero-writes, and one-write drag commits at 1x and 8x.

D060 — Navigate mode never moves a mark and a tap on a saved pin selects it; pin dragging lives in Place pin mode, and e2e specs share the seeded plane by horizontal bands

2026-09-10 · build · accepted · Agent decided alone

Problem

Once persisted pins existed on the seeded capture, the full parallel e2e suite exposed three interaction holes: a pin-mode tap on an existing pin both selected it AND stacked a hidden draft on it; a camera-spec pan that happened to press a pin badge dragged the pin and committed a real PATCH during what the user meant as navigation; and three specs independently aiming their taps at the default pane center collided with each other's pins (a badge's 24px hit box spans hundreds of natural pixels at overview zoom, so per-point margins cannot keep taps clear).

Decision

Persisted pins are draggable only in Place pin mode: in Navigate mode a press anywhere — including on a badge — pans, and camera work can never produce an annotation write. A pin-mode tap on a saved pin is selection, never placement. Draft pins stay draggable in any mode (adjusting a draft after switching back is the shipped flow) because drafts are transient and write nothing. In the e2e suite, gesture specs aim inside a middle document band while the pins spec writes into a bottom band via a shared lattice helper (findClearAim), making cross-spec collision structurally impossible rather than margin-unlikely.

Alternatives considered

  • Keep pins draggable in Navigate mode and make the camera spec avoid badges — That codifies the real defect: an accidental press-and-drag during navigation commits a durable move the user never intended. The interaction contract says navigation moves no mark.
  • Give every e2e spec its own scratch capture — Scratch captures cost real provider work per run and lose the seeded deep-zoom document the canvas specs measure against; band separation keeps the shared plane usable.
  • Clear pins from the store between e2e runs — There is no delete route by design (D059), and test-only database surgery would bypass the API contract the suite exists to prove.

Rationale

Measured end-to-end failures forced each half: the mode gating and tap-selects rule resolve real write-on-pan and draft-stacking behavior, and band separation is the only aim strategy that survives the hit box's natural-pixel size at overview zoom. Both are inside the already-approved canvas direction (D051, D055, D056) and change no shipped scope, so they were safe to decide unilaterally.

Consequences

  • Future marks (rectangles, circles, arrows) follow the same rule: manipulated in their draw mode only; Navigate always pans.
  • E2e specs that place or tap on the seeded plane call findClearAim (middle band for gestures, bottom band for pins) instead of aiming at pane centers.
  • The workspace hint documents the split: Navigate never creates or moves a mark; Place pin mode owns placement and dragging.

Artifacts

  • file src/components/capture-canvas.tsx — Mode-gated pin dragging and tap-selects placement skip.
  • file e2e/canvas-session.ts — findClearAim banded lattice and panUntilNaturalVisible shared helpers.

D061 — Pin mutation lifecycle: explicit context decision with server-derived snapshots, expectedRevision optimistic concurrency on move/edit/delete, tombstone deletes, and authoritative reloads after conflict

2026-09-10 · build · accepted · Agent decided alone

Problem

Comments and mutations on pins raised four coupled contract questions: how a draft's metadata decision stays explicit and honest (never a client-authored element object), how concurrent edits/moves/deletes of the same pin resolve without lost updates or ghost records, what a delete means for numbering, and what the UI shows when the write it attempted was already superseded. D059 had scoped the first build to create+move only; the comment lifecycle needed edit and delete to ship with the same safety properties.

Decision

A draft cannot save until Lucas explicitly chooses a nearby candidate or "No element"; only the candidate's capture-local id (or null) crosses the wire, and the server derives the bounded snapshot from the capture's own persisted manifest (an unknown id rejects the create and persists nothing). Candidates come from a new authorized read route that ranks the persisted manifest deterministically (containment, distance, area, depth, semantic kind, id tie-break) and caps the result at the shared limit. Every mutation carries expectedRevision and commits through one conditional UPDATE guarded by the current revision (drizzle .returning() pattern from the capture transitions): a stale or losing write gets 409 and changes nothing, a write against a foreign or tombstoned pin gets the generic 404, and the UI answers any conflict by reloading the authoritative list instead of overwriting it. Delete is a tombstone: the row stays, the number stays retired (D059 numbering already counts tombstones), and later writes against it are rejected. The context snapshot is immutable for the pin's life: moves and edits never re-query or rebind it. This extends D059's move-only scope note; its numbering and idempotency mechanics are unchanged.

Alternatives considered

  • Let the client post the whole element snapshot it rendered — A client-authored metadata object is forgeable and unverifiable; deriving the snapshot from the persisted manifest keeps the capture the single source of truth and makes the wire contract one small id.
  • Last-write-wins without revision preconditions — A stale drag or edit would silently overwrite a newer comment; the seeded two-session conflict is a real usage pattern (two tabs), and silent loss is the worst possible answer.
  • Hard-delete pin rows — Hard delete would free the number for reuse, and a reused number makes old screenshots and threads lie about which mark they referred to.
  • Keep the editor open with the losing text after a conflict — The losing text is derived from a version that no longer exists; showing the authoritative record and saying why is the honest state, and nothing is silently kept that could be saved over the winner later.

Rationale

The validation contract (VAL-PIN-002/003/008/009) names exactly these behaviors — explicit decision, revisioned mutations, immutable capture-bound snapshots, one authoritative revision under concurrency — and the implementation reuses the already-approved conditional-write and boundary patterns, so no product-direction approval was needed. The e2e suite stages real two-session races (same browser, direct route writes) and asserts the UI settles on the winner.

Consequences

  • Future marks (rectangles, circles, arrows) inherit the same contract: explicit context decision, expectedRevision on every mutation, tombstone deletes, authoritative reload on conflict.
  • The create route rejects unknown element ids and bodies missing the elementId field entirely — undecided drafts cannot persist.
  • The annotations item route now serves PATCH and DELETE and rejects GET/POST/PUT with 405; the context route is GET-only and ready-capture-only.
  • A move conflict supersedes any visible edit/delete conflict notice: one conflict message at a time.
  • Ranking depth, hover preview, and hidden-element filtering beyond the deterministic base ranker remain with the nearby-dom-context-selection feature.

Artifacts

  • file src/lib/server/annotations/pins.ts — Create with elementId decision and server-derived snapshot; updatePin/deletePin with revision-precondition conditional writes.
  • file src/lib/server/annotations/context.ts — Deterministic nearby-candidate ranker and snapshot derivation over the persisted manifest.
  • file app/api/captures/[captureId]/context/route.ts — Authorized ready-capture-only context read route.
  • file src/components/capture-panel.tsx — Draft decision fieldset, snapshot display, edit and two-step delete flows with conflict states.
  • file e2e/pin-lifecycle.spec.ts — Browser contract for decision-required saves, revisioned mutations, staged two-session conflicts, and lost authority.

D062 — Anchor node drags at pointer-down: React Flow nodeDragThreshold set to 0 after e2e caught every drop landing a few pixels short

2026-09-10 · build · accepted · Agent decided alone

Problem

The pin-lifecycle e2e measured a dragged pin landing exactly one pointermove step short of the drop point (3 screen px in a 50px drag) on every run. Reading the installed @xyflow/system source showed why: with the default nodeDragThreshold of 1, startDrag captures the drag origin at the first pointermove past the threshold, so the initial travel is never applied to the node — a systematic drop error, not jitter.

Decision

The canvas sets nodeDragThreshold={0} so drags anchor at pointer-down and the full pointer delta is applied. Click-versus-drag disambiguation stays with d3-drag's clickDistance, which already governs selection clicks, so tap-to-select is unchanged.

Alternatives considered

  • Keep the default threshold and widen the e2e tolerance — That codifies a real user-facing error: every pin drop would land a few pixels away from where Lucas released it, violating the one-natural-pixel contract this canvas is built on.
  • Compensate by adding the threshold window back in our adapter — The swallowed amount depends on pointer speed and event coalescing, so it cannot be reconstructed; anchoring at pointer-down removes the error class entirely.

Rationale

The failure was measured end-to-end and the library source confirmed the mechanism; the fix is one prop inside the already-approved React Flow canvas, so it was safe to decide unilaterally. jsdom cannot run d3-drag, so the e2e drag assertions are the regression guard.

Consequences

  • Any future draggable node (shape handles, arrow endpoints) inherits pointer-down anchoring from the same React Flow props.
  • The e2e drag specs keep their strict tolerances (1 natural px plus one screen pixel) as the standing regression guard.

Artifacts

  • file src/components/capture-canvas.tsx — nodeDragThreshold={0} with the rationale comment.
  • file e2e/pin-lifecycle.spec.ts — The failing measurement that caught the swallowed initial travel.

D063 — Fix the tall-motion-v1 focus flake in place: wire interaction counters before the scripted caret focus and exclude that focus by target

2026-09-10 · validate · accepted · Agent decided alone

Problem

The real-provider motion suite intermittently failed its zero-interaction-counters assertion: tall-motion-v1 focused #caret-box before wiring its interaction counters, and Chromium defers focusin delivery for a page that is not focused yet, so the fixture's own scripted focus could land after the listeners were attached and be counted as an interaction (the known flake from user-testing round 1).

Decision

Edit tall-motion-v1 in place rather than publishing a v2: the interaction counters are wired before the #caret-box focus call, and the focusin listener ignores events targeted at the fixture's own #caret-box, so the fixture's scripted focus is excluded deterministically no matter when Chromium delivers the event. Republished through npm run fixtures:publish, which re-verified a byte-exact readback and updated host.json.

Alternatives considered

  • Only reorder — wire the counters first, focus second, no exclusion — Ordering alone cannot remove the race: a deferred focusin can arrive at any later moment, and when delivery is synchronous the fixture's own focus would be counted deterministically. The assertion would fail always instead of intermittently.
  • Publish the fix as a new tall-motion-v2 version — The change is render-invisible — identical layout, text, and pixels — so a new version would churn the publish script, host.json, and suite expectations for no capture-behavior difference. The versioning rule exists to protect the pixel-diff reference, which this change cannot move.
  • Drop the scripted focus (and the caret motion case) — The caret case needs a real focused editable region to prove capture hides carets; removing it weakens the motion matrix the contract names.

Rationale

The mission feature fixture-tall-motion-focus-ordering, created from the user-testing round 1 handoff, directed the in-place ordering fix; the exclusion-by-target is the smallest mechanism that makes the counter deterministic under deferred event delivery. Safe to decide unilaterally: it is a fixture-only change with no product surface, and the real-provider suite proves it three consecutive runs.

Consequences

  • A render-invisible fix (identical layout, text, and pixels) may be made in place on a published capture fixture; render-visible changes still require a new -vN version.
  • The focusin counter still proves capture never interacts: the capture pipeline performs no focus calls at all, so excluding the fixture's own caret-box focus cannot mask a real interaction.

Artifacts

  • file test/fixtures/capture/tall-motion-v1.html — Counter wiring now precedes the scripted caret focus; the focusin listener excludes the fixture's own caret-box focus.
  • file test/fixtures/capture/host.json — Republished durable fixture record with the new tall-motion-v1 sha256.

D064 — Candidate context preview as a transient inert React Flow node, a quiescent marker for the context panel, and an authorized verbatim manifest read route

2026-09-10 · build · accepted · Agent decided alone

Problem

The nearby-DOM context selection feature needed three mechanisms that constrain every future canvas and panel change: how Lucas previews a candidate's captured bounds before choosing (without the preview ever persisting or intercepting canvas gestures), how e2e specs survive the context panel's async candidate render (a radio detached mid-click caused the known full-gate flake), and how the deferred HTTP-surface half of VAL-CAPTURE-006 (an authorized fetch of persisted manifest JSON scanned for forbidden-source values) can run at all when no manifest read route existed in milestone 1.

Decision

Preview is a dedicated contextPreview node type: a pointer-transparent, aria-hidden, non-draggable child of the capture frame whose position and size are exactly the candidate's persisted manifest rect, driven by transient workspace state that clears on hover/focus/touch end, choice, cancel, save, and plane switch. The context panel exposes a data-candidates-state quiescent marker (loading/ready/failed) and gives the No-element radio a stable key and value so the loading-to-ready transition can never detach it; all e2e radio interactions wait for the marker first. A new GET /api/captures/[captureId]/manifest route serves the persisted manifest bytes verbatim (no-store) under the same live-authority guard as the context route, making the sentinel scan a scan of the persisted record itself.

Alternatives considered

  • Draw the preview as an SVG/HTML overlay outside React Flow's node tree — A second coordinate system would need its own transform bookkeeping to satisfy the one-natural-pixel contract at 1x and 8x; a frame-parented node inherits the plane's transform for free and the e2e measurement proves the alignment.
  • Fix the radio flake by waiting for a fixed timeout or retrying clicks in specs — Timeouts are exactly the load-dependent pattern the flake thrived on; a semantic quiescent marker is both the spec signal and a self-documenting panel state, and the stable radio key removes the detach window entirely.
  • Serve the manifest through the existing context route with a flag — The context route projects a bounded, tip-relative candidate ranking; the sentinel scan needs the exact persisted record, unprojected. A separate verbatim read keeps each route's contract single-purpose.

Rationale

The mission feature nearby-dom-context-selection directed all three mechanisms, including the quiescent marker and the deferred sentinel-scan evidence. Safe to decide unilaterally: every choice is inside the approved canvas architecture, adds no dependency, and is covered by focused Vitest suites plus three new e2e specs. One implementation discovery matters for the future: React Flow 12.11 computes a node wrapper's pointer-events from interactivity and ignores the Node pointerEvents field, so the preview's pointer-transparency is carried by node.style, which spreads after the computed value.

Consequences

  • Any new transient canvas decoration should follow the contextPreview pattern: a namespaced-id child node of the frame, fully inert, fed by server-projected data only.
  • Specs must never click inside the context panel before data-candidates-state reads ready or failed; the marker is now part of the panel's public contract.
  • GET /api/captures/[captureId]/manifest is the authorized surface for whole-manifest reads; it returns 401 anonymous and the same generic 404 for missing, non-ready, and manifest-less captures.
  • React Flow 12.11 node pointer-transparency must be set via node.style.pointerEvents, not the Node pointerEvents field.

Artifacts

  • file src/lib/canvas/flow-model.ts — contextPreview node adapter: exact manifest-rect child of the frame, null on invalid rects.
  • file src/components/capture-panel.tsx — Quiescent marker, stable No-element radio, and hover/focus/touch preview triggers with inert hostile-field rendering.
  • file app/api/captures/[captureId]/manifest/route.ts — Authorized verbatim manifest read enabling the deferred VAL-CAPTURE-006 sentinel scan.
  • file e2e/manifest-scan.spec.ts — Zero-SENTINEL scan plus anonymous 401, closed-menu exclusion, and stabilized animated-subtree rect equality.

D065 — Run-scoped e2e cleanup runs in the Playwright global teardown, never in afterAll

2026-09-10 · build · accepted · Agent decided alone

Problem

Real-capture e2e suites create run-scoped Turso rows and Blob objects against the one shared local store while sibling specs observe it: every signed-in page auto-selects the newest project's first device. Two full-gate runs showed that deleting run rows in a spec's afterAll races sibling workers — projects.spec failed its console-error gate on a 404 from a capture its page was still displaying, and a hijacked pins.spec run left a stray pin that FK-blocked the captures delete. A run-scoped project whose root URL is a query-suffixed Chickpea URL also matched findReadyTarget's seeded-first regex, pulling concurrent specs onto the disposable plane.

Decision

Deletion is coordinated by timing, not scope: suites register their run id plus annotation body prefixes in a file registry (e2e/.run-cleanup/, gitignored) in afterAll — a fast local write that survives test failure — and the Playwright global teardown, which runs after every worker's last page has closed, deletes annotations, blobs, leases, captures, idempotency keys, pages, and projects by run id, verifies absence, removes the registry entry, and fails the run on any leftover. The registry lives outside test-results/ because Playwright wipes that directory at the next run's start, so a crashed run's entries survive to the next teardown, which mops them up idempotently. Run-scoped projects are additionally rooted on the fixtures host (links-v1, manifest-v1, tall-motion-v1) so their URLs can never match findReadyTarget's seeded Chickpea regex.

Alternatives considered

  • Keep per-spec afterAll deletion and add retries/FK ordering — Cannot fix the observer race: a sibling worker's page legitimately holds the newest project open while this suite's afterAll runs; timing, not ordering, is the hazard.
  • A dedicated teardown worker spec at the end of the run — Playwright gives no ordering guarantee across files beyond serial-mode within one file, and worker crashes would skip it; the global teardown hook is the one place guaranteed to run after all pages close.
  • Registry inside test-results/ — Playwright wipes outputDir at the start of the next run, so a crashed run's registry — the only record of leaked rows — would vanish before the next teardown could mop it up.

Rationale

Agent-autonomous inside the mission's sanctioned pattern (the mission explicitly anticipates an orchestrator-approved Playwright global teardown for cross-suite cleanup). The teardown hook is the single point where no browser page can still observe the store, and the file registry survives both individual test failures and whole-run crashes.

Consequences

  • Any future real-capture e2e suite registers its run id and annotation body prefixes in afterAll instead of deleting rows itself; deletion, verification, and loud failure live in e2e/global-teardown.ts.
  • Run-scoped projects use fixture-host URLs only; a Chickpea URL with a run-id query suffix would silently hijack every seeded-target spec in the run.
  • Foreign annotations found on a run's captures are reported and removed so a hijack can neither leak rows nor FK-block cleanup.
  • A cleanup failure fails the whole gate run instead of leaking silently into the shared store.

Artifacts

  • file e2e/run-cleanup.ts — The registry helpers suites call from afterAll.
  • file e2e/global-teardown.ts — The deferred, verified deletion executed after all workers close.

D066 — The root route is a branded landing page: the pinata mark directly above the URL capture entry, a brief value proposition, a fully static example of a marked-up capture, and a clear sign-in path

2026-09-10 · build · accepted · Human directed

Problem

The root route was a bare product blurb plus a password prompt. An anonymous visitor could not tell what Pinata does, and the URL capture entry — the product's front door — only existed behind sign-in. The user asked for a real landing page, deliberately saved for the end of the build and kept (small, already specified) through the D051 descope.

Decision

Rebuild / as the branded landing page for every visitor. The pinata mark — an accent tile carrying the product's own pin teardrop with a starburst, drawn once in src/lib/brand-mark.ts — renders inline above the capture entry and doubles as the favicon (app/icon.svg), with zero external image assets. The entry form takes a required root URL plus optional additional-URL rows with one visible primary action. Below the hero, a fully self-contained static example render (fixture data in src/lib/example-capture.ts) shows a screenshot region with two numbered pins, a two-entry comment thread, and a DOM metadata panel; it makes no /api/* request and no database access, and it depicts saved thread content only — no founder reply UI (D051). Anonymous visitors additionally get the editor sign-in prompt; a signed-in editor lands on the same branded page with the project form active, the project list below it, and the example after that.

Alternatives considered

  • A separate marketing page at / with the editor home at a different route — The user asked for the URL capture entry on the root page itself; splitting routes adds navigation the demo does not need.
  • Render the example from a real recent capture in the database — The anonymous surface must not read project data, and the contract requires the example to trigger no /api/* request and no database access; bundled fixture data is the honest static answer.
  • A raster or externally hosted logo image — Zero external assets is a stated requirement; one shared inline-SVG source for logo and favicon also keeps the two marks from drifting apart (locked by test/brand-mark.test.tsx).

Rationale

This is exactly what the user directed on 2026-09-08 and confirmed keeping on 2026-09-09; the implementation mechanics inside that direction are D067.

Consequences

  • The logo and the favicon share one mark source (src/lib/brand-mark.ts); app/icon.svg cannot consume CSS custom properties, so the two brand colors live in that module and must equal --accent/--surface, enforced by test/brand-mark.test.tsx.
  • Narrows D045: the New project toggle inside the Projects region is replaced by the always-active project form in the landing hero; D045's four-state list machine is unchanged.
  • The example render depicts pins with their comments as saved content; the founder reply UI remains deferred with threads (D051) and must not appear as a live control.
  • The favicon is served same-origin at /icon.svg; no external image request may appear on / (asserted in e2e/landing.spec.ts).

Provenance

Human: “make sure to create a root page where the URL capture is entered, with a pinata logo above the form field. we need a decent landing page. maybe even render an example of a marked up page with comments and DOM metadata clear”

Artifacts

  • screenshot screenshots/D066-landing.png — The anonymous landing at 1440px: mark above the capture entry, value proposition, static example with two numbered pins, comment thread, and DOM metadata panel, then the sign-in prompt.
  • file src/lib/brand-mark.ts — The single brand-mark source shared by the logo and the favicon.
  • file src/components/example-capture.tsx — The fully static example render; the suite asserts it carries no client runtime or fetch.

D067 — Anonymous capture entries park in same-tab sessionStorage and route to the on-page sign-in prompt; the editor form consumes the draft exactly once

2026-09-10 · build · superseded · Agent decided alone

Problem

VAL-LANDING-003 requires the anonymous capture entry to survive the sign-in round trip: submit as anonymous, sign in, land back on / with the URL input retained, then create the project with exactly one POST /api/projects. The handoff mechanism had to move user-typed URLs from the anonymous page to the post-login editor form without an unauthorized write.

Decision

The anonymous entry validates a non-blank root client-side, parks { rootUrl, urls } in sessionStorage under pinata:capture-draft, and routes to the existing on-page sign-in section by moving focus to the password field (scroll honoring prefers-reduced-motion) — no request is made. After sign-in, the server re-renders / for the verified session; the editor home reads and removes the draft in a mount effect (StrictMode-safe: the first read wins), and the always-active project form initializes straight from it. Creating the project is the form's existing single POST with its own idempotency key.

Alternatives considered

  • A dedicated /login route carrying the draft in query parameters — User-typed URLs would land in the address bar, history, and logs, and a new route duplicates the existing on-page prompt; the contract's flow is same-page before and after sign-in.
  • localStorage instead of sessionStorage — The handoff is same-tab by construction (sign-in happens in the tab that submitted); localStorage would resurrect stale drafts in later sessions and other tabs.
  • POST the draft to the server before authentication and reconcile after login — Anonymous project writes are unauthorized by design; the authorization boundary must not gain a pre-auth staging write for a cosmetic convenience.

Rationale

Mechanical plumbing inside the user-directed landing direction (D066): it changes no product direction, touches no authorization rule, and the failure mode (storage unavailable or a malformed entry) degrades to a blank form. The consumed-once read prevents a reload from resurrecting a stale draft; malformed entries are validated and discarded, never thrown.

Consequences

  • The draft is same-tab only by design; signing in via a different tab starts with a blank form.
  • The editor form renders after the mount-time draft check, so initial values are never overwritten by a late read.
  • The Playwright global teardown additionally matches idempotency keys by stored result payload, because form-driven creations key on a fresh UUID while their result carries the run-scoped URLs.

Artifacts

  • file src/lib/capture-draft.ts — The validated save/take-once draft handoff.
  • file e2e/landing.spec.ts — The end-to-end proof: no anonymous write, retained draft after sign-in, exactly one POST /api/projects answered 201.

Superseded by D103.

D068 — Deploy to Vercel production behind SSO protection, fixing the framework preset and adding a Protection-Bypass-for-Automation secret for the smoke

2026-09-10 · build · accepted · Agent decided alone

Problem

The first production deployment (D051) had to satisfy two hard constraints at once: deployment protection stays ON for the pinata project, and the production smoke (editor login, real Chickpea capture, private asset denial, pin persistence, /reqs hub) must run against the real deployment. The project was also still on the "Other" framework preset from before the Next.js stack landed, so the GitHub-triggered production builds of the validated commit were failing with Error status.

Decision

Set the project framework preset to Next.js via the Vercel API, deployed the exact validated commit 523dcd9 with `vercel deploy --prod` (the CLI attaches the local git metadata, so VERCEL_GIT_COMMIT_SHA and the deployment record both carry the commit SHA the /reqs pages display), and enabled Vercel Protection Bypass for Automation: one high-entropy project secret, marked as the VERCEL_AUTOMATION_BYPASS_SECRET source, sent only as the x-vercel-protection-bypass header by the smoke tooling. SSO protection (all_except_custom_domains) is unchanged; the secret lives in Vercel and a local 0600 scratch file, never in the repository, logs, or .env.local.

Alternatives considered

  • Disable deployment protection for the smoke window, then re-enable it — The feature requires protection kept ON; a window with protection off is exactly the weakening the mission forbids, and the unprotected interval would be observable.
  • Run the production smoke against the local production build instead (the milestone-1 substitution) — The substitution rule expired with milestone 2: this feature exists precisely to prove the real deployment — real Vercel runtime, real env configuration, real protection posture.
  • Authenticate the smoke browser through Vercel SSO as the user — The user"s Vercel account session is off-limits to agents; no headless credential path exists, and asking for an interactive login defeats automated re-verification.

Rationale

Protection Bypass for Automation is Vercel"s documented mechanism for exactly this situation: it keeps the SSO wall up for every party without the secret while letting automation through with a revocable, rotatable credential. Both hard constraints hold at once. The choice is mechanical execution inside the already-directed deployment scope (D051, and the readiness note that the deployment feature must fix the framework preset), not a product-direction change, so it was safe to decide without a round trip.

Consequences

  • Production identity: https://pinata-lucasdickeys-projects.vercel.app (alias pinata-tau.vercel.app) serves the validated commit; the /reqs pages display the same SHA as the deployment metadata.
  • Anyone holding the automation-bypass secret reaches the protected deployment; it is rotatable from project settings and its use invalidates existing deployments" copies until redeployed.
  • The production Chickpea project (public id rOqjVjw0G0Cf) is demo data for the live pins checkpoint and stays in the shared Turso/Blob stores; local e2e helpers therefore pin themselves to the OLDEST seeded Chickpea project so the suite never writes into the demo data.
  • GitHub-triggered production builds now work (Next.js preset), so orchestrator pushes to main deploy automatically.
  • scripts/production-smoke.mjs plus e2e/production-smoke.spec.ts make the whole smoke repeatable after any redeploy.

Artifacts

  • file scripts/production-smoke.mjs — The executable production smoke: protection posture, /reqs SHA identity, login, Chickpea capture drive, asset authorization, pin persistence, recapture isolation.
  • file e2e/production-smoke.spec.ts — The browser-faithful production loop: UI sign-in, 8x dense-cell pin with explicit element choice, reload persistence, mobile plane isolation, unauthorized denial, SSO-wall check.
  • file scripts/chickpea-baseline.mjs — The same-run direct-browser Chickpea baseline the capture landmarks are checked against (VAL-CAPTURE-011).

D069 — Split the public landing from the editor workspace: / stays marketing, /pins is the app, /pins/new holds the project form

2026-09-10 · build · accepted · Human directed

Problem

One route was doing three jobs. `/` rendered the branded hero, the always-active project form, the static example capture, and the whole project workspace stacked underneath. Every visit to the working surface therefore paid for a screenful of marketing before reaching the canvas, and the project form sat permanently open whether or not a project was being created. The landing's hub link had the same collapsing problem in miniature: the five titles 'Requirements, architecture, milestones, decisions, and evals' were one anchor pointing at /reqs, so clicking 'Architecture' landed on the hub rather than /reqs/architecture, even though all five routes already existed.

Decision

Three routes with one job each. `/` is the public landing only — hero, anonymous capture entry, static example, sign-in, and five separate hub links generated from REQUIREMENTS_NAV. A verified editor session at `/` is redirected to `/pins`. `/pins` is the working surface: a compact header (home, New project, Sign out), the project workspace, and nothing that competes with the canvas. `/pins/new` carries the project form on its own route and hands off to `/pins` once the project commits. Both editor routes redirect an unverified visitor back to `/`, and all three share one server-side predicate (src/lib/server/auth/editor-page.ts) so the boundary cannot drift between them. Sign-in navigates rather than re-rendering in place, and routes to `/pins/new` when a parked anonymous draft (D067) is waiting so that handoff still works.

Alternatives considered

  • Keep one route and hide the hero once projects exist — The surface would still be one component deciding what it is at render time, and the project form would have no address of its own — there would be nothing for a 'New project' link to point at.
  • Keep the project form permanently mounted above the workspace on /pins — That is the cost the split exists to remove: the form is used once per project and occupies the space the canvas needs on every visit.
  • Make the five hub titles anchor links into sections of one /reqs page — The five routes already exist and already render from separate repository sources; pointing the titles at them is both less work and the behavior the titles already promise.

Rationale

The user reported both symptoms together — the hub titles all landing on the same page, and the marketing surface crowding the app — and they have the same shape: one thing standing in for several. Splitting by route makes each surface addressable, lets the redirect rather than a conditional render carry the authorization boundary, and gives the 'New project' link somewhere to go.

Consequences

  • The editor surface has a stable address, so 'New project' and 'Back to pins' are ordinary links and browser history behaves.
  • Sign-in and sign-out now navigate explicitly (router.replace) instead of relying on router.refresh() to pick up a server-side redirect.
  • A parked anonymous draft is consumed at /pins/new rather than on /, so hasCaptureDraft() was added to let sign-in choose the destination without consuming the draft.
  • Every e2e spec that reached the editor through / had to learn the new landing; the shared signIn() helper absorbs most of it, and projects.spec.ts now opens the form through the header link.
  • The .home-main:has(.workspace) width override is gone: the workspace has its own shell (.pins-main) and is unconditionally wide.

Provenance

Human: “"Requirements, architecture, milestones, decisions, and evals" <-- these all point ot the same page, rather than the same page then the jump to the anchor point. i.e. clicking architecture goes to ./reqs/ rather than ./reqs/architecture/. UI changes: 1. (image 1) only show the new project entry when the user is at root, otherwise have a "new project" link that directs back to this view. more space efficient. move the "see what a marked-up capture" (image 2) to the root as well, don't show when in the primary app. 2. the changes in #1 above suggest we need a root route and a ./projects endpoint (or ./pins) endpoint for the primary app itself.”

Human approved: “/pins ... Redirect straight to the app route”

Artifacts

  • file app/page.tsx — The public landing, and the redirect that sends a verified editor to /pins.
  • file app/pins/page.tsx — The editor workspace route, gated by redirect.
  • file app/pins/new/page.tsx — The project form on its own route.
  • file src/lib/server/auth/editor-page.ts — The single shared editor-session predicate the three routes agree on.
  • file src/components/landing.tsx — LandingLinks: five links from REQUIREMENTS_NAV instead of one anchor.

D070 — Collapse the project rail into nested native disclosures, open only around the current selection

2026-09-10 · build · accepted · Human directed

Problem

The left rail printed every project's entire page and device tree at once. With more than a couple of projects the canvas was pushed off screen, and the rail gave no way to put a project away once its captures were reviewed.

Decision

Two levels of native <details>: one around the whole rail labelled 'Projects' with a count, and one per project around its pages and devices. The rail starts open; a project starts open only when it holds the current selection, and stays wherever the reader last put it. The disclosure state is session-only React state, never persisted. Each project keeps its heading for assistive technology, now visually hidden inside the disclosure while the summary carries the visible title.

Alternatives considered

  • A hand-rolled button with aria-expanded and a controlled region — More code and more ways to get the announcement wrong, for behavior <details> already provides correctly and without script.
  • Default every project collapsed — The canvas is showing something; collapsing the control that produced it hides the reader's own context.
  • Default every project expanded, with collapse available — That is the current behavior plus a control nobody has a reason to press; it does not recover the space the change exists to recover.

Rationale

Native disclosures are keyboard-operable and correctly announced with no dependency and no script, which matches the repository's standing constraint. Anchoring the default to the selection means the rail is never hiding the thing the reader is looking at, while every other project folds away.

Consequences

  • Device buttons in non-selected projects are no longer in the layout, so e2e specs reach them through a revealPlane/clickPlane helper that expands the owning project first — driving the UI the way a reader would.
  • The 'Projects' <h2> left the editor surface: the rail's own summary is now the heading for the list, and the region keeps its accessible name via aria-label.

Provenance

Human: “3. in the primary app, set it so that Projects in the left-hand rail are moved inside of a collapsable/expandable nav element to save space.”

Artifacts

  • file src/components/project-workspace.tsx — The nested disclosures and the selection-anchored default.
  • file e2e/canvas-session.ts — revealPlane/clickPlane: the specs expand a collapsed project before selecting a plane.

D071 — List every pin in a table below the canvas, with a Markdown export for pasting into an agentic IDE

2026-09-10 · build · accepted · Human directed

Problem

The side panel shows one pin at a time. That is right for editing, and useless for the thing the pins are ultimately for: handing a page's worth of feedback to a coding agent. Getting all of it out meant clicking each pin in turn and copying the comment by hand, losing the element context that makes a note actionable without the screenshot.

Decision

A table under the canvas listing every pin on the active capture — number, natural-pixel position, element summary and DOM path, and the comment — plus one 'Copy all as Markdown' control. The Markdown is produced by a pure function (src/lib/pin-export.ts) so the exact text that reaches the clipboard is unit-testable; it heads the block with the page, device, and version, and gives each pin its position, element, path, bounds, and its comment quoted verbatim. Selecting a row selects the pin everywhere else, so the table is a second route to the same state rather than a second copy of it.

Alternatives considered

  • A per-row copy button instead of one copy-all — The user asked for the whole set in one paste; per-row copying is the manual work the table exists to replace.
  • Export JSON — The destination is a chat-style agent prompt, where prose with inline context reads better than a structure the agent has to interpret.
  • Extend the side panel to list full pin detail — The panel is screen-fixed and narrow by design so it survives panning; a full table there would either overflow or force the canvas to shrink.

Rationale

The element snapshot is the only durable record of where a note points — the capture is a screenshot and the manifest is never re-derived after save (VAL-PIN-003, VAL-PIN-008) — so an export that omits it is not actionable. Emitting the body inside a blockquote keeps arbitrary comment text intact without escaping the content that matters most.

Consequences

  • Two surfaces now list every pin, so component tests naming a pin have to scope to the panel or the table.
  • The clipboard is not available in every context; a refused write is reported and the table text stays selectable as the fallback.

Provenance

Human: “Pin+annotation set as list view - at the bottom of each project view, rather just one at a time in the right-hand pin manipulation component, have an array of pins - pin #, pin location (from attached DOM object), and copy. this should make it easier to copy past into an agentic IDE/dev tool.”

Human approved: “Table + "Copy all as Markdown" button”

Artifacts

  • file src/lib/pin-export.ts — The pure Markdown rendering the clipboard receives.
  • file src/components/pin-table.tsx — The all-pins table and the copy control.
  • file test/pin-export.test.ts — The export contract: element context present, comment bodies verbatim.

D072 — Build a ten-slide interactive walkthrough of Pinata with Remotion, borrowing the repository's existing imagery

2026-09-10 · wrap · accepted · Human directed

Problem

Explaining the product and the process mattered as much as building it, and the only explainer surfaces were prose: README, the /reqs hub, and the decision log. Nothing walked a first-time viewer through what Pinata is and how the four steps fit together in a form that could be played, paused, and pointed at during a demo.

Decision

A Remotion composition of exactly ten slides (title, the problem, who it is for, the four steps, guardrails, the stack and gate, the decision trail), 1920 x 1080 at 30 fps, about 104 seconds end to end. Every slide paints with the application's own color tokens and the shared brand mark, and the imagery is borrowed rather than invented: the brand exploration board that was already committed at the repository root (cropped into tiles), and the two dashboard screenshots already attached to D004 and D066. One slide table (remotion/walkthrough/slides.ts) carries the chapter names, durations, and a prose transcript, and is the single source for the video, the chapter navigation, and the on-page notes.

Alternatives considered

  • A static HTML or Markdown slide deck — The user asked for Remotion specifically, and a static deck cannot show the pin dropping, the canvas zooming with the pins staying put, or the thread growing, which are the parts of the product that prose explains worst.
  • A screen recording of the live app — A recording goes stale the moment the UI changes and cannot be regenerated from source; a composition re-renders from the same code the tests cover.
  • More than ten slides, one per feature — The user sized it at about ten, and ten is what a viewer will sit through before the live demo; the /reqs hub already holds the long form.

Rationale

Directed by the user. Remotion keeps the walkthrough in code, typed by the same tsconfig as the app and covered by the same gate, and its Player lets the same composition run in the deployed product, so the walkthrough is demoable over a link rather than only from a checkout. Borrowing the committed board and screenshots keeps the visual language honest: it is the product's own material, not stock.

Consequences

  • The dependency allowlist widens for the first time since D021: remotion and @remotion/player in the app bundle, @remotion/cli dev-only (D073 records where the walkthrough lives and why).
  • public/walkthrough/ now holds derived copies of imagery that already existed elsewhere in the repository; test/walkthrough-slides.test.ts fails if any referenced image is missing.
  • Slide copy is a second statement of the product story next to README and REQUIREMENTS.md; when scope changes, the slide table is one more place to update.
  • npm run walkthrough opens Remotion Studio and npm run walkthrough:render writes out/pinata-walkthrough.mp4; the MP4 is generated, git-ignored, and never committed.

Provenance

Human: “use the remotion library and create an interactive walkthrough on what pinata is and how it works. think maybe 10 total slides and borrow from any imagery that exists in the repository”

Artifacts

  • file remotion/walkthrough/slides.ts — The ten-slide table: chapters, durations, transcript.
  • file remotion/walkthrough/Walkthrough.tsx — The composition, one component per slide id.
  • file public/walkthrough/brand-board.webp — The borrowed brand exploration board the title slide draws its tiles from.

D073 — Host the walkthrough inside the app at /walkthrough through @remotion/player, keep the Remotion CLI dev-only, and widen the dependency allowlist to match

2026-09-10 · wrap · accepted · Agent decided alone

Problem

D072 asked for Remotion and for the walkthrough to be interactive, but not where it should live. Remotion can run three ways: in Studio from a checkout, as a rendered MP4, or in the browser through @remotion/player. Each has a different footprint on the one-lockfile, allowlisted-dependency repository, and only one of them is reachable from the deployed product.

Decision

The composition lives in remotion/ inside the one package, typed by the app's tsconfig. The Next.js app mounts it at the public route /walkthrough through @remotion/player, wrapped in a chapter list that seeks the player to a slide, Previous/Next controls, arrow-key navigation, and the transcript for the chapter on screen. remotion and @remotion/player join the application dependencies; @remotion/cli is a devDependency for Studio and the MP4 render. All three are pinned exactly and added to scripts/lib/approved-deps.mjs, the allowlist the gate enforces. The landing page links to the walkthrough; REQUIREMENTS_NAV is untouched because the walkthrough is not a Markdown-backed requirements source and must not enter the dogfood URL array.

Alternatives considered

  • A standalone Remotion project in a subdirectory with its own package.json — A second lockfile and a second toolchain to keep green, nothing the deployed app could show, and the gate would not cover it.
  • Remotion Studio only, no in-app page — Studio needs a checkout and a dev server; Pinata is shown over a link and screen share, and the working rule is that anything not demoable in a browser in under a minute is deprioritized.
  • Render the MP4 and commit it, embed a <video> — A binary in git that drifts from the composition on every edit, and no chapter navigation; the Player gives the same frames with seeking for free.

Rationale

Decided without asking because the user had already fixed the direction (Remotion, interactive, about ten slides) and these are the mechanics inside it. The AGENTS.md rule is no unrequested dependencies; Remotion was requested, and the allowlist edit is the recorded mechanism for admitting it, not a new direction. Choosing the Player over Studio-only follows the standing demo-first rule. Keeping the CLI dev-only keeps the production bundle to what the page needs.

Consequences

  • The application bundle carries the Remotion runtime on /walkthrough only; no other route imports it.
  • /walkthrough is public and anonymous like /reqs; it calls no /api route and loads nothing from outside the app, which e2e/walkthrough.spec.ts asserts along with an axe sweep at desktop and 390 px.
  • test/walkthrough-player.test.tsx stubs the Player and proves the page's own contract: one chapter per slide, seeking to a slide's first frame, the marker following frame updates.
  • Anyone wanting the MP4 runs npm run walkthrough:render locally; out/ is git-ignored.

Artifacts

  • file src/components/walkthrough-player.tsx — The in-app player with chapter navigation.
  • file scripts/lib/approved-deps.mjs — The allowlist, now naming the three Remotion packages.

D074 — Drop a pin with one gesture: no interaction modes, a composer anchored at the pin, a pre-selected element with overrides, and keyboard equivalents

2026-09-12 · build · accepted · Agent proposed, human approved

Problem

Placing a pin took a mode switch (Navigate to Place pin), a click, a comment typed in the side panel far from the click, a mandatory choice between a nearby element and 'No element' before Save would enable, the save itself, and a switch back to Navigate to pan again. The on-page explanation of that ran to about 120 words. The milestone-1 live checkpoint (EVALS M1-LIVE-3, M1-LIVE-4) had already recorded the owner unable to discover the pin mechanism; adding the modes made it explainable, not discoverable.

Decision

One interaction model with no modes. A press on the screenshot that releases without moving past the placement slop drops a draft pin; a press that moves pans the camera; a saved pin drags by its badge in every state, committing one revisioned move at drag end as before. The comment composer opens in a popover anchored beside the draft badge, screen-fixed against the plane's transform so it never leaves the viewport; the side panel keeps the full detail view and the pins list. The nearby-element decision stays explicit in the data (the client still sends a candidate id or null and the server still derives the snapshot, D061) but the top-ranked candidate is pre-selected and shown as one chip with 'Change' and 'No element' controls, so the common case is type, then Enter. Keyboard: Enter saves a draft (Shift+Enter inserts a newline), Escape cancels, J/K or the arrow keys step through saved pins, N drops a draft at the viewport center. The two-mode toolbar and the long hint are removed; a short line under the canvas names the three verbs.

Alternatives considered

  • Keep the modes but shorten the hint — The hint was long because the interaction had that many steps; a shorter hint would describe the same steps less completely.
  • Keep the mandatory undecided state for the element choice — An undecided state exists to prevent an accidental attachment. A visible pre-selected chip with a one-click 'No element' prevents the same accident without blocking every save behind a radio group.
  • Compose in the side panel as before, but auto-focus the textarea on drop — Focus jumping across the screen is the problem in a smaller form: the editor's eyes are on the pin, and the comment belongs next to it.

Rationale

Proposed by the agent from the code review and the checkpoint evidence, approved by the human in the same message as the rest of the overhaul. The data contract does not change: drafts still write nothing, one create per save, one move per drag, the snapshot still derives server-side from the persisted manifest. Only the number of user actions between intent and a saved pin changes, from six to two.

Consequences

  • INTERACTION_MODES and the Place pin toolbar are removed from capture-canvas; specs that clicked 'Place pin' now press and release on the frame.
  • The click-versus-drag distinction carries product meaning, so PLACEMENT_SLOP_SCREEN_PX is a published interaction boundary. It also governs a press on a saved pin: nodeDragThreshold stays 0 for drop fidelity (D062), but a press whose tip travelled no further than the slop selects the pin and writes nothing, so a tap can never commit a sub-pixel move.
  • Pins are draggable in the only mode there is; an accidental nudge is undone by dragging back, and the move is one revisioned write as before.
  • The composer is positioned inside the visible canvas frame, falling back to the browser viewport when the frame is too small, so it never covers the toolbar or the side panel yet is always on screen. Re-settling a draft by dragging it re-runs pre-selection, and a late candidates answer can never overwrite a newer set.
  • Keyboard shortcuts must not fire while focus is in a text field. They are editor-only: the founder's read-only plane has no focusable region and no shortcuts, which D078 may extend later.

Provenance

Agent proposed: “Make dropping a pin a one-gesture act, not a mode plus three decisions. Today the editor switches to Place pin mode, clicks, writes the comment in a panel on the far side of the screen, must pick a nearby element or explicitly choose 'No element' before Save enables, saves, then switches back to Navigate to pan again. The hint paragraph explaining this runs about 120 words, and the live checkpoint recorded the owner unable to find the mechanism at all. The fix: drop the mode switch (a click that doesn't move drops a draft; a drag pans; pins drag by their badge in every state); compose at the pin in a popover anchored to the draft; pre-select the top-ranked nearby element as a chip with 'change' and 'none' as overrides so Save becomes Enter after typing; keyboard: Escape cancels, Enter saves, arrows or J/K step between pins, N starts a new pin at the viewport center.”

Human approved: “yep let's do them, prioritizing 1, 2 and 5 first. then find the feature set associated with doing a square bounding box with comments, too. draft the decision records, then spin up sub-agents to do the work where parallelization is doable”

Artifacts

  • file src/components/capture-canvas.tsx — The canvas, now modeless.
  • file src/components/capture-panel.tsx — Detail panel; the composer moves to an anchored popover.

D075 — Close the feedback loop: a pin lifecycle (open, replied, resolved), per-role last-seen with unread counts, a founder pin list, and sharing in the project header

2026-09-12 · build · accepted · Agent proposed, human approved

Problem

The product promises the founder's reply, but a reply was invisible until the editor selected each pin and opened its thread. No count said where new replies were. Neither role could mark a note done, so a reviewed project looked identical to an unreviewed one. The founder had no list of the notes waiting for them, only badges on a screenshot. And 'Share with founder', the action that starts the loop, was inside the collapsed project rail.

Decision

Pins gain a status: open on create, replied when the other role appends to the thread, resolved when either role resolves it. Resolving and reopening are reversible and each writes an append-only thread entry of a new system kind naming who did it, so the immutable chronology (REQUIREMENTS 6) records the state change rather than a mutable column alone. Each role's last-seen time is stored per project (editor) and per capability session (founder); a reply is unread for a role when it postdates that role's last view of the pin. The hierarchy read returns unread and open counts per capture and per project so the rail, the overview, and the header can show them without extra requests; viewing a pin's thread marks it seen. The founder view gains a pin list above the canvas with comment excerpts, status, and unread markers, in pin-number order, each entry focusing its pin. 'Share with founder' moves to the project header next to the title with its state (no link, active vN, revoked) always visible; its panel behavior is unchanged.

Alternatives considered

  • Email or push notifications — There is no email or identity for the founder by design; a notification channel is a separate product decision. Counts inside the product are the prerequisite either way.
  • Resolved as a mutable flag only, no thread entry — A flag can flip silently. The thread is the record both roles trust, and a status change is a thing that happened in the conversation.
  • Let only the editor resolve — The founder is the one who acts on a note; 'done' is their statement. The editor can reopen.

Rationale

Proposed by the agent, approved by the human. Status and last-seen are additive columns and one new thread-entry kind; the append-only triggers and the founder's read/reply-only boundary are untouched. Counts ride on the existing hierarchy read so the workspace's default traffic does not grow.

Consequences

  • Schema migration: annotations.status, thread_entries.kind (message or status), and an annotation_views table for per-role last-seen. Value domains on the two existing tables are enforced by RAISE(ABORT) triggers rather than CHECK constraints, because drizzle-kit adds a CHECK by recreating the table, and recreating thread_entries would drop the append-only triggers from migration 0001.
  • Status rule: open on create; the founder's first message moves open to replied in the same transaction as the entry; editor follow-ups never change status; a reply on a resolved pin leaves it resolved; reopen returns to replied if any founder message exists, else open. Status changes never bump the annotation revision.
  • Unread means message entries by the other role after the viewer's last-seen time; status entries never count. The founder's viewer key is the capability version, so rotating the link starts the founder's read state fresh and no session id is stored.
  • New authorized routes: resolve, reopen, and seen per annotation, for the editor session and for a founder capability session on that project; the founder capability grants exactly reply, resolve, reopen, and seen.
  • The project header shows the share control with its state loaded on mount, one status read per selected project; the header title is not a heading so existing heading queries keep resolving to the rail.
  • The pin table and the Markdown export carry status; the walkthrough's share slide learns about status under D078.

Provenance

Agent proposed: “Close the feedback loop with pin state and unread signals. The reply is invisible until the editor selects each pin and opens its thread; nothing says '3 new replies on the pricing page'; neither side can mark anything done; the founder has no list of what they're expected to look at. Build: a pin lifecycle (open, replied, resolved; either role can resolve; reversible and logged as a thread entry so the append-only rule holds); unread reply counts on pins, on each capture in the rail, and on the project header, from a last-seen timestamp per role; a founder-side pin list with comment excerpts and status; move 'Share with founder' out of the collapsed rail into the project header with the link state visible.”

Human approved: “yep let's do them, prioritizing 1, 2 and 5 first. then find the feature set associated with doing a square bounding box with comments, too. draft the decision records, then spin up sub-agents to do the work where parallelization is doable”

Artifacts

  • file src/components/founder-view.tsx — Founder pin list and resolve control.
  • file src/lib/server/projects/hierarchy.ts — Unread and open counts ride the hierarchy read.

D076 — Drive capture from the server with progress and one automatic retry; the browser tab is no longer required for a project to finish

2026-09-12 · build · accepted · Agent proposed, human approved

Problem

Capture dispatch was driven only by the editor's browser tab (D049): the client POSTed each pending attempt to the dispatch route, polled the hierarchy, and stopped polling after ten minutes. Closing the tab after creating a project stalled the remaining captures; an attempt that stopped responding waited for a manual retry; and after creation the editor saw rows reading 'Queued' with no sense of how long the project would take.

Decision

The server continues capture work after it has answered. Project creation and retry schedule the first dispatches to run after the response is written (Next.js after()), each dispatch schedules the next pending attempt of the same project when it finalizes, and a secret-protected sweep route re-drives any pending or stale attempt for a scheduled job to call, so a chain that dies mid-way is picked up. The durable two-lease cap remains the only concurrency authority. A retryable failure or a stale attempt is retried once automatically, recorded as a new attempt exactly as a manual retry is; a second failure surfaces with its catalog reason and a retry control at project level. The hierarchy read reports progress per project: attempts done, remaining, the page currently capturing, and an estimate from the median duration of this project's finished attempts. The client keeps its driver as a fallback re-driver, so nothing regresses where after() cannot run. Pinning is available on any ready capture while the rest continue.

Alternatives considered

  • A queue service (Upstash, Inngest) — A new external dependency and account for a two-user product; after() plus a sweep route gives the same guarantee within the platform already in use.
  • A cron-only design — Vercel cron granularity depends on plan (daily on Hobby); progress that waits a day is not progress. Cron is the backstop, not the driver.
  • Keep client-driven capture and simply extend polling — The failure mode is the tab going away, which longer polling does not address.

Rationale

Proposed by the agent, approved by the human. The dispatch and execution modules already run a whole capture inside one request, so continuing that work after the response reuses the same code path with a different trigger. The one-retry policy mirrors what the owner did by hand in the checkpoint (M1-LIVE-5).

Consequences

  • Function duration limits on the deployment must cover one capture (TOTAL_CAPTURE_TIMEOUT_MS is 90 s): the four routes that may run a capture export maxDuration = 300, which is the Vercel Hobby ceiling with Fluid compute enabled and is rejected without it. Check the project setting at deploy time; a chain cut off at the limit leaves a stale attempt that the sweep or the client fallback picks up.
  • A new sweep route accepts either the Vercel cron bearer secret (CRON_SECRET) or a dedicated header secret (CAPTURE_SWEEP_SECRET); the names join the deployment's environment variable list in README, and vercel.json schedules a daily sweep as the backstop.
  • Automatic retry consumes attempt budget (MAX_CAPTURE_ATTEMPTS_PER_PROJECT) and is bounded by MAX_AUTOMATIC_CAPTURE_RETRIES, published as 1: an automatic attempt is never retried automatically again.
  • Progress fields are additive on the hierarchy response; the polling policy stays read-only and stops when nothing is in progress.
  • Server continuation would make every credentialed e2e run that creates a project spend real Browserless captures behind the tests' backs, so PINATA_SERVER_CAPTURE=off disables continuation and the sweep's re-drive; the Playwright server runs with it off, and it must never be set in a Vercel environment, like PINATA_AUTH_DISABLED.

Provenance

Agent proposed: “Take capture off the browser tab and show honest progress. The dispatch driver runs in the editor's tab: close it and capture stalls, polling gives up after ten minutes, and a stopped attempt waits for a manual retry. After creating a project the editor lands on rows that say 'Queued' with no expectation set. Change: drive capture server-side so a project finishes whether or not a tab is open; show progress per project (which page is capturing, how many remain, a rough time from observed durations); retry a stopped attempt once automatically, then surface the failure reason with a retry button at project level; let pinning start on the first ready capture while the rest continue.”

Human approved: “yep let's do them, prioritizing 1, 2 and 5 first. then find the feature set associated with doing a square bounding box with comments, too. draft the decision records, then spin up sub-agents to do the work where parallelization is doable”

Artifacts

  • file src/lib/server/captures/dispatch.ts — Dispatch, now continued server-side.
  • file src/components/editor-home.tsx — Client driver kept as fallback; progress shown.

D077 — A project overview: capture grid with counts, device toggle per page, cross-capture next and previous pin, and project-scoped pin table and export

2026-09-12 · build · superseded · Agent proposed, human approved

Problem

Everything about pins was scoped to one capture: the list, the table, the Markdown export, the selection. A Chickpea project is four pages times two devices, so reviewing it meant visiting eight planes with no view of where the notes were or what was left to read.

Decision

The workspace opens on a project overview: one card per capture, in page order, Desktop then Mobile, showing a thumbnail, the page URL, capture state, and pin, open, and unread counts from D075; a card opens its capture in the canvas. Within a page, Desktop and Mobile become a toggle above the canvas instead of two rail entries, and the rail lists projects and pages only. Next pin and Previous pin controls (and J/K per D074) step through every pin in the project in page, device, number order, switching capture as needed and remembering each plane's camera. The pin table and 'Copy all as Markdown' move to project scope with page and device columns; the per-capture view filters the same table.

Alternatives considered

  • Keep per-capture scope and add a project export button only — Export is the last step; the missing thing is orientation while reviewing.
  • All captures on one canvas — Explicitly a non-goal (REQUIREMENTS): independent planes keep coordinates and camera simple, and eight full-page captures on one surface is unreadable.

Rationale

Proposed by the agent, approved by the human, ordered after D074, D075, and D076 at the human's direction. The thumbnail is the existing private asset served through the authorizing route, sized by CSS; no new storage.

Consequences

  • The rail lists projects and pages only (the two-level disclosure from D070 stays for projects) plus one 'Overview of <project>' entry per project, because a native details toggle cannot double as a selection without feedback loops. A page entry opens its Desktop capture, or Mobile when Desktop is not usable.
  • The device toggle sits in a toolbar row above the stage with Back to overview and Next / Previous pin, not inside the canvas's camera row, because it has to exist when the capture is not ready and the canvas is not mounted.
  • A new editor-only read, GET /api/projects/<publicId>/annotations, returns every live annotation across the project's ready captures in page, device, version, number order; stepping and the project-scoped table read it, while the canvas and panel keep the per-capture list.
  • Pin numbering stays per capture; the project-scoped table shows page and device so numbers are unambiguous, and the table's scope choice persists while a project stays selected.
  • The Markdown export format (D071) gains a heading per capture, skipping captures with no pins; the single-capture format is unchanged.

Provenance

Agent proposed: “Give the editor a project-level view instead of eight separate planes. Pins, the pin table, and Markdown export are all scoped to one capture, so reviewing a four-page project means visiting eight planes with no sense of where the notes are. Build: a project overview thumbnail grid with pin counts and unread badges; Desktop and Mobile as a toggle on the same page view, not separate tree entries; 'Next pin' and 'previous pin' that cross page and device boundaries; the pin table and 'Copy all as Markdown' at project scope with page and device columns.”

Human approved: “yep let's do them, prioritizing 1, 2 and 5 first. then find the feature set associated with doing a square bounding box with comments, too. draft the decision records, then spin up sub-agents to do the work where parallelization is doable”

Superseded by D144.

D078 — Speak the user's language: pins named by their comment and element, internals behind a Details disclosure, and a reading-first founder view on phones

2026-09-12 · build · accepted · Agent proposed, human approved

Problem

Both views identified a pin as 'Pin 1 at natural pixel (462, 410)' and listed image hash, version, state, and natural size beside it. The founder, who is not a developer, saw the same. Two long instruction paragraphs explained interactions that D074 makes self-evident, and the founder view stacked a tall canvas above the panel, so on a phone the notes were a screen below the picture.

Decision

Pins are named by an excerpt of their comment and, when attached, the element's short label: 'Pin 3 · "Annual (save 20%)" toggle'. Coordinates, natural size, version, state, and image hash move behind one 'Details' disclosure in the editor panel and leave the founder view entirely. The instruction paragraphs are replaced by a one-line verb strip beside the controls (drop a pin: click the page · move: drag it · read: click a pin) and a keyboard list inside Details. The founder view on narrow widths puts the pin list (D075) first, the canvas second, and opens the canvas scrolled to the chosen pin when an entry is tapped.

Alternatives considered

  • Tooltips over the technical labels — Hover-only content is already excluded by the quality attributes under test, and the labels were noise, not underexplained.
  • Hide internals for the founder only — The editor does not need coordinates while working either; a disclosure keeps them one click away for debugging.

Rationale

Proposed by the agent, approved by the human; sequenced last because it rewrites copy across the surfaces the other records change.

Consequences

  • One helper (markLabel in src/lib/canvas/marks.ts) produces every mark name: kind and number first, then a comment excerpt of at most 60 characters, then the element's short label (text, accessible name, or tag, at most 40 characters), joined with a middle dot. Every surface uses it, so the wording cannot drift, and coordinates never appear in a name.
  • Coordinates, box bounds, version, capture state, screenshot size, image hash, and the keyboard list live inside one closed Details disclosure in the editor panel and nowhere in the founder view. 'Natural size' stays as the camera button's name and 'px natural' stays in the Markdown export, which is written for an agent, not a founder.
  • Every test that matched 'at natural pixel' or the hint text was rewritten against the new labels; accessible names keep the pin or box number first so screen-reader order is stable.
  • Tapping a founder list entry centers that mark on the canvas at any width and scrolls the canvas into view only below 48rem, honoring reduced motion. The founder's comment is read from the thread's first entry rather than shown twice.
  • The walkthrough's annotate and share slides now show a drawn box, the label pattern, and a resolved status; the anonymous landing joined the axe sweep at both widths.
  • The pin table's first column now repeats the comment and element; whether the separate Comment column should go is left open.

Provenance

Agent proposed: “Replace implementation vocabulary with the user's vocabulary, especially for the founder. Both views identify pins as 'Pin 1 at natural pixel (462, 410)' and show image hash, version, and state facts. Change: identify pins by comment excerpt and element; move coordinates, hash, version, and capture state behind a 'Details' disclosure in the editor and remove them from the founder view entirely; rewrite the two long instruction paragraphs into three short verbs beside the controls they describe; treat the founder view as a reading experience first, notes list on top on a phone, canvas as the way to see where a note points.”

Human approved: “yep let's do them, prioritizing 1, 2 and 5 first. then find the feature set associated with doing a square bounding box with comments, too. draft the decision records, then spin up sub-agents to do the work where parallelization is doable”

D079 — Rectangle marks: drag to draw a box, comment and context like a pin, shared numbering, resizable, readable by the founder

2026-09-12 · build · accepted · Human directed

Problem

The human asked for a square bounding box with a comment. The requirements and schema already anticipated rich marks (REQUIREMENTS 4, annotations.kind allows rectangle, MIN_SHAPE_SIZE_PX is published) but nothing drew, stored, listed, or exported one.

Decision

A rectangle is a second annotation kind on the same canvas and in the same numbering sequence as pins. Drawing: with the modeless model of D074, a press that moves on empty screenshot pans; holding Shift while pressing, or choosing the Box tool, draws a rectangle from press to release in natural pixels, clamped to the frame and rejected under MIN_SHAPE_SIZE_PX. The draft opens the same anchored composer; nearby candidates are ranked by overlap with the box instead of distance to a point, with the largest-overlap element pre-selected. Saved rectangles render as a stroked box with the number badge at the top-left corner, are movable by dragging the box and resizable by eight handles, each committing one revisioned geometry write. Geometry is {x, y, width, height} at geometry_version 1. Rectangles carry threads, status, unread, and resolve exactly as pins do, appear in the pin table and Markdown export with their bounds, and render read-only for the founder with no handles.

Alternatives considered

  • Free-form drawing or circles first — The human asked for the box. Circles and arrows reuse the same resize and geometry path later.
  • A separate numbering sequence for boxes — One sequence per capture keeps 'note 4' unambiguous in the thread, the table, and the export.
  • Draw by clicking two corners — Press-drag-release is the gesture every design tool uses; two clicks would collide with pin placement.

Rationale

Directed by the human. The persistence rules from D059 and D061 (server numbering, idempotent create, revisioned mutation, tombstone delete, server-derived snapshot) apply unchanged; only the geometry shape and the candidate ranking are new.

Consequences

  • The annotation routes stop assuming kind is pin: the create body names the kind by its geometry key (tip or rect), PATCH accepts rect beside tip and body, geometry must match the row's kind, and list, resolve, reopen, seen, threads, and hierarchy counts cover both kinds. Pin idempotency digests keep their earlier shape so old keys replay; geometry_version stays 1 for both kinds.
  • Overlap ranking is share-first, not raw area: the fraction of the element inside the box, then overlap area, then distance, then the existing tie-breakers. Raw area alone would hand every box to the largest enclosing landmark.
  • Resize handles are hand-rolled rather than React Flow's NodeResizer so one clamping authority governs draw, move, and resize and the gesture is testable in jsdom. Drawing wins over panning through a capture-phase pointer listener while Shift is held or the Box toggle is armed; React Flow's selection key is disabled so Shift never means multi-select.
  • Box interiors are pointer-transparent: clicking inside a box still drops a pin; a box is selected and moved by its stroke or its badge, and handles keep the 24 px hit target.
  • Circles and arrows remain unbuilt; NEXT.md says so. A keyboard key to arm the box tool and touch drawing without the toggle are also left for later.

Provenance

Human: “then find the feature set associated with doing a square bounding box with comments, too.”

Artifacts

  • file src/lib/server/annotations/schemas.ts — Kind-aware geometry validation.

D080 — Project header feedback counts the current version, matching the rail badges

2026-09-13 · build · accepted · Agent decided alone

Problem

The project header summed feedback across every capture of a project, while the page rail badges and the overview cards summed only each device's selected (current) capture. Retrying a page that already had pins — the old attempt keeps its pins, the new attempt is selected and empty — made the header total exceed the sum of the badges beneath it, so the same screen disagreed with itself. Found in review of the D074–D079 pull request.

Decision

Compute the project header total (project.feedback on the hierarchy read) over each device's selected capture, the same basis the rail badges and overview cards already use. Pins on superseded attempts stay counted per capture (captureFeedback) and stay listed in the explicit all-versions pins table; they simply no longer inflate the current-state headline.

Alternatives considered

  • Count all attempts everywhere, including the rail badges — A page badge would then show feedback for pins that live on a superseded capture the editor is not viewing; clicking the page shows the empty current capture, so the badge and the canvas would disagree.
  • Leave the header summing all captures — Keeps the header inconsistent with the badges it sits directly above and with the overview cards, which is exactly what the review flagged.

Rationale

Safe to decide unilaterally inside the granted latitude to tackle the review concerns: it aligns three existing UI surfaces onto the basis two of them already used, changes no schema or wire shape, and preserves the historical record — per-capture counts and the all-versions table still expose superseded pins. It is reversible in one function if the product later prefers the all-history reading.

Consequences

  • The header reads as current-state; the all-versions pins table is the single surface that enumerates superseded attempts' pins.
  • project.feedback on the hierarchy wire is now a selected-capture sum, not an all-captures sum.

Provenance

Human: “go ahead and fix B1, then merge. and tackle each of your concerns thereafter.”

Artifacts

  • file src/lib/server/projects/hierarchy.ts — Header total summed over selected captures.

D081 — Restate the milestones against what is actually built, add a fourth slice for depth on the loop, and hold accounts and multiple users until last

2026-09-15 · wrap · accepted · Human directed

Problem

The milestone document had gone stale enough to mislead. It called milestone 1 in progress and milestones 2 and 3 pending, while the repository had shipped the canvas, pins, element context, founder capability links, append-only threads, a production deployment, a project overview, rectangles, server-driven capture, and a six-record UX overhaul. The document is a deliverable rendered live at /reqs/milestones, so a reader checking status was being told something false. It also had no place to put the UX overhaul, which was not in the original three-slice plan, and no statement of what comes after milestone 3.

Decision

Rewrite the milestone document against the repository as it stands. Milestone 1 is complete and cites its live checkpoint. Milestone 2 is 'built, awaiting checkpoint': every item is implemented and covered by the gate, and none of it has been driven by the owner in a browser, so the document's own completion rule forbids calling it done. Milestone 3 lists what is finished and names three remaining items: circles and arrows, shipping the current build and running the deferred checkpoint against it, and the rectangle resize-handle ergonomics. The UX overhaul gets its own dated section between milestones 2 and 3 rather than being folded silently into either. A new milestone 4 collects depth on the feedback loop: the re-review loop after a founder ships a change, working a project at scale, and the editor on a phone. A new milestone 5 holds accounts, identity, per-user projects, and anything resembling multiplayer until everything above is done.

Alternatives considered

  • Mark milestones 2 and 3 complete and move on — The document's own rule is that a milestone is complete only when the owner's live checkpoint has run. It has not run since milestone 1, so the whole feedback loop is machine-validated and human-unvalidated. Calling it complete would hide exactly the risk worth surfacing.
  • Fold the UX overhaul into milestone 3's polish line — It was six records and the largest single body of work in the repository, and it reshaped surfaces milestones 2 and 3 had already delivered. Burying it as 'polish' would misrepresent both its size and the fact that it was unplanned.
  • Leave the roadmap at three milestones and treat anything further as open-ended — The owner asked for an ordering, and an ordering with no place for accounts is how accounts end up half-built early.

Rationale

Directed by the owner, who asked for a review of the log and the app and a decision on how to move forward, and who fixed the one ordering constraint: accounts and multiple users come last. The rest of the ordering follows from what changes whether the tool gets used. Shipping outranks new features because the live deployment is at commit 523dcd9 and everything since is invisible to anyone but a developer running the repository. Arrows outrank the re-review loop because 'move this there' is the one instruction the current mark vocabulary cannot express, and the product exists to make directional feedback unambiguous. The re-review loop outranks scale and mobile because the conversation currently has no ending.

Consequences

  • The deferred live checkpoint now blocks two milestones rather than one, and it cannot run until the current build is deployed: three migrations, two environment variable names, and a function-duration setting stand in the way.
  • Accounts, identity, durable session revocation, and per-user projects are explicitly out of scope until milestone 4 closes, so any design pressure toward them is answered by pointing at this record.
  • Milestone 4 is named but not specified; each of its three items will want its own record before it is built.
  • The status vocabulary now distinguishes 'built, awaiting checkpoint' from 'complete', which applies retroactively to any future slice.

Provenance

Human: “Review the decision log, the current app state, and decide how we can move forward on our milestones. I want to save auth/identify and real multi -user support for last, after fleshing out all the high value functionality.”

Artifacts

  • file docs/MILESTONES.md — The restated milestones.

D082 — Circle marks: a square-constrained ellipse drawn with an armed tool, sharing every rule rectangles already follow

2026-09-15 · build · accepted · Agent proposed, human approved

Problem

The published architecture and the geometry catalog have described circles since mission planning (MIN_SHAPE_SIZE_PX names them, 'circles stay square'), the annotations table has allowed kind='circle' since the first migration, and D079 made the whole annotation path kind-generic. Nothing drew one. A circle is the right mark for 'this region', where a rectangle's corners imply an alignment that is not being asserted.

Decision

A circle is a rectangle whose geometry is constrained square and whose renderer is an ellipse inscribed in that square. Geometry is {x, y, size} at geometry_version 1, where size is both the width and the height in screenshot-natural pixels, no smaller than MIN_SHAPE_SIZE_PX, clamped inside the frame. Drawing: an armed Circle tool in the mark-tool group beside the existing Box tool; the drag's larger dimension sets the size, so an off-square drag still yields a circle, and the tool disarms after one gesture or on Escape. Everything else is what a rectangle already does: the same anchored composer, nearby elements ranked by overlap with the circle's bounding square, shared per-capture numbering, the badge at the bounding square's top-left, move by the stroke, resize by the same handles constrained to stay square, one revisioned write per gesture, threads and status and unread exactly as pins and boxes have them, and a read-only rendering with no handles for the founder.

Alternatives considered

  • A free ellipse with independent width and height — The geometry catalog published 'circles stay square' before any of this was built, and a free ellipse adds a second resize contract for no expressive gain over a rectangle.
  • Reuse the rectangle kind and render round when width equals height — The kind is what the author meant, not a coincidence of dimensions; a resize that happened to equalise the sides would silently change the mark's meaning.

Rationale

Proposed by the agent as the cheap half of finishing the mark vocabulary, approved by the human in the same message that ordered the milestones. Constraining to a square keeps one resize contract, honours a boundary published before implementation, and makes the circle a renderer and a clamp rather than a new geometry family.

Consequences

  • The mark-tool group grows from one toggle to three (box, circle, arrow) inside a labelled group; each arms exactly one gesture and disarms after it or on Escape, and B, C, and A arm them from the keyboard, so D074's no-modes rule still holds. An armed tool wins over Shift; Shift with nothing armed still draws a box, unchanged from D079.
  • A circle offers four corner handles and no edge handles, published as CIRCLE_RESIZE_HANDLES. With a square constraint an edge handle would have to move the two perpendicular edges as well, which is not what a north or east handle shows.
  • Circles inherit the low-zoom handle ergonomics weakness: four fixed-screen-size corners still crowd a near-minimum circle and the top-left one paints over the badge. Four is less crowded than a rectangle's eight, not fixed, and NEXT.md records it.
  • No migration was needed: annotations.kind has admitted circle and arrow since the first migration, and the 0006 triggers constrain status and thread-entry kind rather than the annotation kind. Only BUILT_ANNOTATION_KINDS widened.

Provenance

Agent proposed: “Milestone 3's headline item is rich marks, and only rectangles were built. Circles and arrows finish the vocabulary. Arrows are the valuable one: 'move this there' is the one instruction a pin and a box cannot express, and the product exists to make directional feedback unambiguous. The schema, the geometry minimums, and the kind-generic annotation path from D079 already allow both; only the renderers, the gestures, and their geometry validation are missing.”

Human approved: “Review the decision log, the current app state, and decide how we can move forward on our milestones. I want to save auth/identify and real multi -user support for last, after fleshing out all the high value functionality.”

D083 — Arrow marks: a two-endpoint straight arrow whose head carries the meaning and the element context

2026-09-15 · build · accepted · Agent proposed, human approved

Problem

Every mark the product has is a place: a pin is a point, a rectangle and a circle are regions. None of them can say 'move this there', 'this should point at that', or 'these two are out of order' — the instructions that make feedback directional rather than merely located. The architecture has described arrows as straight edges between two draggable endpoints since mission planning, and MIN_ARROW_LENGTH_PX has been published since, but nothing drew one.

Decision

An arrow is two points and a direction. Geometry is {start: {x, y}, end: {x, y}} in screenshot-natural pixels at geometry_version 1, with the straight-line distance between them no smaller than MIN_ARROW_LENGTH_PX and both endpoints clamped inside the frame. The head is at `end`: that is where the arrow points, so that is what the mark is about. Nearby elements are ranked from the head point using the existing point ranking, not the overlap ranking, and the pre-selected candidate is the element under the head. Drawing: an armed Arrow tool; press sets the tail, release sets the head. Each endpoint is independently draggable after save and the whole arrow moves by its shaft, each gesture committing one revisioned write. The badge rides at the tail so it never covers the thing being pointed at. Rendering is a line with a filled head whose stroke and head scale with zoom to stay visible without changing the stored geometry, and the founder sees it read-only with no endpoint handles.

Alternatives considered

  • Anchor the element context at the tail, or at the midpoint — The tail is where the reader's eye starts and the head is what the note is about. Attaching the captured element to the tail would file 'move this into the header' under whatever the arrow happened to start on top of.
  • Curved or elbowed arrows — Freehand and curved arrows are a published non-goal; a straight arrow is unambiguous and needs no control points.
  • Render as a React Flow edge between two endpoint nodes — Edges are a graph abstraction the canvas does not otherwise use, and the capture frame is a single parent node. Two child nodes plus a drawn line keeps one clamping authority and matches how rectangles already work.

Rationale

Proposed by the agent and approved by the human as the item that most changes what the product can say. The straight-line constraint and the head-anchored context are what keep an arrow a directional instruction rather than a decoration.

Consequences

  • The context route is unchanged: the caller picks the ranking by mark kind, so a circle asks for the overlap ranking with its bounding square and an arrow asks for the point ranking at its head. The route has served both shapes since D079.
  • A new published boundary, ARROW_HIT_TOLERANCE_CSS_PX of 12, with POLICY_VERSION bumped to 2026-09-15.1. It is exactly half MIN_HIT_TARGET_CSS_PX, so the band around the shaft is a full-size target; the drift test asserts both that relationship and that the tolerance exceeds the placement slop.
  • Hit-testing is the browser's own stroke test rather than a hand-written one: a transparent line of twice the tolerance takes the pointer while the wrapper stays transparent, so distance-to-segment falls out of rendering. The pure oracle exists anyway because jsdom does no hit-testing.
  • An arrow has no area, so its React Flow node box is the endpoints' bounds padded by the chrome, and a shaft drag applies the difference from a drag-start snapshot rather than re-deriving geometry from the padded box, which keeps length and direction exact.
  • The badge rides at the tail and reveal centres on the shaft's midpoint while the extent stays the endpoints' bounds, so a long arrow zooms out to fit rather than centring on something off screen.
  • Arrow endpoint handles share the low-zoom crowding problem; the wide shaft band means the arrow is still grabbable. Recorded in NEXT.md.
  • The mark vocabulary the requirements named is now complete. Freehand and curved arrows remain non-goals.

Provenance

Agent proposed: “Milestone 3's headline item is rich marks, and only rectangles were built. Circles and arrows finish the vocabulary. Arrows are the valuable one: 'move this there' is the one instruction a pin and a box cannot express, and the product exists to make directional feedback unambiguous. The schema, the geometry minimums, and the kind-generic annotation path from D079 already allow both; only the renderers, the gestures, and their geometry validation are missing.”

Human approved: “Review the decision log, the current app state, and decide how we can move forward on our milestones. I want to save auth/identify and real multi -user support for last, after fleshing out all the high value functionality.”

D084 — Build the founder link and read/reply loop after all, on a parallel branch, partly un-deferring D051

2026-09-10 · build · accepted · Human directed

Problem

D051 descoped requirements 6 and 7 — persistent revocable founder links and append-only two-way threads — to fit the remaining budget, and the requirements, architecture, and eval documents were marked accordingly. But the founder's reply is the whole point of the product: without it Pinata is usable by the editor alone, and the owner wanted it usable with friends. Everything underneath the feature already existed: the schema carried the share columns and the thread_entries table with its reject-update and reject-delete triggers, and the boundary catalog already published a reply quota. What made it risky was timing, not difficulty — the demo build was working and a live pins checkpoint was still pending.

Decision

Build the founder stream in parallel on a branch (feat/founder-links) rather than deferring it further or interleaving it with the demo build, and keep it strictly additive so main's working behaviour cannot regress. The branch carries the same npm run validate gate as main. Its decision records stay in docs/decisions/drafts/ while it is in flight, so concurrent work never violates the sequential id rule, and are folded into the log with real ids at merge — which is what D085 through D090 are.

Alternatives considered

  • Keep it deferred and ship the editor-only build — The founder's reply is the product's reason to exist; leaving it out makes the demo a screenshot annotator.
  • Build it directly on main alongside the demo work — It touches auth, the asset route, and the canvas. A half-finished founder session on main could break a working demo the day it is shown.
  • Wait until after the live pins checkpoint — The work is independent of the canvas and pin code the checkpoint exercises, so serializing it only spends calendar time.

Rationale

The human directed the stream, its priority relative to the demo, and the branch shape. This is a bounded exception to D010's commit-straight-to-main, not a reversal of it: main keeps the gate before every commit, and the branch carries the same gate.

Consequences

  • D051 still governs the rest of the descope — rich marks, the visual design pass, and the hardening list stay deferred — but requirements 6 and 7 are no longer vision, so the deferral markers those documents carry for founder links and threads come out.
  • The founder surfaces must satisfy the security boundaries already published in docs/ARCHITECTURE.md: no referrer, no indexing, digests only in the database, generic denials. D085 through D090 record how each was met.
  • The founder e2e spec is environment-gated, so CI proves the anonymous surface only; the credentialed founder paths are proven by a local gate run with .env.local present.
  • The founder loop is on main but not yet in the production deployment, and it has had no live checkpoint with the owner.

Provenance

Human: “let's do this work (below) in parallel, as it's not crucual to the demo, but will make it more usable for future use in the wild with friends. open a separate branch for this and we'll own a PR later as well. I don't want to block/break what's already working.”

Artifacts

  • file src/components/founder-view.tsx — The founder's read/reply-only shell.
  • file src/lib/server/founder/capability.ts — Capability issue, rotate, and revoke.
  • file e2e/founder.spec.ts — Environment-gated end-to-end proof of the link and reply loop.

D085 — Founder capability sessions are stateless: a signed cookie bound to project id and capability version, re-checked against the project row on every request

2026-09-10 · build · accepted · Agent decided alone

Problem

A founder link must persist until rotated or revoked, and rotation or revocation must end every existing founder session at once. The editor session revokes through an in-memory denylist (a documented per-instance weakness); copying that for founders would let a rotated capability keep working on another serverless instance until absolute expiry.

Decision

Mirror the editor session code for signing, lifetime, renewal, cookie attributes, and the double-submit CSRF proof, but bind the founder payload to {project id, capability version} and have the guard re-read the project row on every request: the project must be live, the digest present, share_revoked_at null, and share_token_version equal to the session's version. No founder session state is persisted, so no migration is needed and no denylist exists to go stale.

Alternatives considered

  • A founder_sessions table with explicit revocation rows — A new table and migration for state the project row already expresses; every request would still have to read the project row to learn about rotation.
  • Reuse the editor session token format with a role claim — One token format for two authorities invites confusion; a distinct prefix (f1 versus v1) covered by the signature makes cross-verification impossible by construction, and the tests prove it.

Rationale

The brief explicitly says the version binding is what invalidates sessions, and the durable row is the only authority that changes on rotation; reading it per request is one indexed primary-key lookup. Safe to decide alone because it adds no schema, no dependency, and no new boundary constant.

Consequences

  • Founder sessions reuse EDITOR_SESSION_ABSOLUTE_LIFETIME_MS and EDITOR_SESSION_RENEWAL_THRESHOLD_MS; a founder-specific lifetime would need a new boundary constant and a POLICY_VERSION bump.
  • Every founder-authorized route performs one extra project-row read; asset delivery for founders costs one lookup more than for the editor.
  • There is no founder logout route; closing the browser or waiting for expiry ends the session, and the editor's rotate/revoke ends it immediately.

Artifacts

  • file src/lib/server/founder/session.ts — Signed founder session bound to project and version.
  • file src/lib/server/founder/guard.ts — Live binding check on every request.
  • file test/server/founder-routes.test.ts — Rotation and revocation end existing sessions.

D086 — No new boundary constants for founder links: reuse the editor session policy, AUTH_REQUEST_MAX_BYTES, ANNOTATION_REQUEST_MAX_BYTES, FEEDBACK_BODY_MAX_CHARS, and the published reply quota

2026-09-10 · build · accepted · Agent decided alone

Problem

The boundary catalog is versioned and drift-checked against docs/EVALS.md and docs/ARCHITECTURE.md; adding any constant requires a POLICY_VERSION bump published in both documents, and this branch is forbidden from editing ARCHITECTURE.md while another stream closes the docs out.

Decision

Every limit the founder and thread surfaces need already exists in the catalog: the exchange body is capped by AUTH_REQUEST_MAX_BYTES (a 43-character token fits comfortably), reply bodies by ANNOTATION_REQUEST_MAX_BYTES and FEEDBACK_BODY_MAX_CHARS, founder session lifetime by the editor session constants, and the founder reply quota by REPLY_MAX_PER_WINDOW / REPLY_WINDOW_MS. The token length itself (32 bytes, 43 base64url characters) lives in the capability module as an implementation constant, not a published boundary, because it is a cryptographic minimum rather than a tunable policy.

Alternatives considered

  • Add FOUNDER_SESSION_* and FOUNDER_TOKEN_BITS to the catalog with a POLICY_VERSION bump — Forces edits to ARCHITECTURE.md and EVALS.md that this branch may not make, for values that would simply duplicate the editor's.

Rationale

The brief allows EVALS.md edits only when a drift test forces them; reusing constants means no drift test fires and the catalog stays at 2026-09-09.2. If the owner later wants a shorter founder lifetime, that is a one-constant catalog change with its own decision record.

Consequences

  • A founder session lives 12 hours and renews inside the last 2 hours, exactly like the editor's.
  • The boundary catalog and both policy documents are untouched on this branch.

Artifacts

  • file src/lib/server/founder/capability.ts — Token size as an implementation constant.
  • file src/lib/server/threads/throttle.ts — Reply quota from the existing catalog values.

D087 — Founder reads share the editor's capture routes through one editor-or-founder authorizer, with a founder-scoped hierarchy route and a same-origin exchange route under /api/founder

2026-09-10 · build · accepted · Agent decided alone

Problem

The founder view needs the project hierarchy, per-capture pin lists, private screenshot bytes, and per-pin threads. Duplicating those reads under a founder namespace would fork the list and delivery logic; admitting founders on the editor routes risks changing the editor-only denial shapes the existing tests pin.

Decision

One shared authorizer (src/lib/server/founder/reader.ts) tries the editor guard first and consults a founder cookie only when one is present; every founder failure answers with the editor's own denial, so anonymous, foreign-project, rotated, and revoked callers are byte-identical to what the routes always returned. The asset route, the pin-list GET, and the new thread route use it. The hierarchy read and the token exchange are founder-only and live under /api/founder/[publicId]; manifest and context reads stay editor-only because the read-only view never places a pin.

Alternatives considered

  • A parallel /api/founder/... namespace for every read — Duplicates delivery and listing logic and doubles the byte-exact asset proof surface.
  • Admit founders on manifest and context routes too — The founder cannot place marks, so those reads have no consumer; the smallest admitted surface is the safest.

Rationale

The brief asks for the asset route to admit founders while denying everyone else exactly as today; a single authorizer that returns the editor's denial on any founder failure is the direct way to keep those denials identical, and the founder-specific asset tests prove one denial body for every failure case.

Consequences

  • GET /api/captures/[captureId]/annotations now serves founders of that capture's project; POST/PATCH/DELETE remain editor-only.
  • The existing editor-only asset and annotation route tests pass unchanged.

Artifacts

  • file src/lib/server/founder/reader.ts — Editor-or-founder authorizer.
  • file test/server/capture-asset-founder.test.ts — Founder bytes and the one denial shape.

D088 — Thread replay is intent-bound without a digest column: the same key returns the committed entry only when body and role match, otherwise it conflicts

2026-09-10 · build · accepted · Agent decided alone

Problem

Thread appends must be idempotent per the existing (annotation_id, idempotency_key) unique index, and pin creation binds a key to a payload digest through the idempotency_keys table. thread_entries has no digest column and adding one means a migration for a table whose rows can never be updated.

Decision

Look up the existing entry by (annotation, key); replay it with 200 when its body and actor role equal the request's, answer 409 when they differ, and converge on the winner if the unique index fires under concurrency. The founder quota admission runs only after the replay check and validation, so a retried or invalid reply never consumes quota.

Alternatives considered

  • Record thread appends in idempotency_keys with a payload digest — Two sources of truth for one row; the entry itself already carries every field the digest would cover.
  • Add a payload_digest column to thread_entries — A migration for information derivable from the row.

Rationale

The entry row is immutable, so comparing its stored body and role is exactly as strong as comparing a digest of them. Safe to decide alone: no schema change, and the behavior matches the pin store's replay-or-conflict contract.

Consequences

  • A client that reuses a key with different text gets a 409 and must choose a new key.
  • No thread-related rows are ever written to idempotency_keys.

Artifacts

  • file src/lib/server/threads/entries.ts — Replay-or-conflict append.
  • file test/server/thread-entries.test.ts — Replay, conflict, quota admission ordering, and the trigger proof.

D089 — The editor's share control is closed by default and reads nothing until opened; the workspace's default traffic is unchanged

2026-09-10 · build · accepted · Agent decided alone

Problem

The workspace tests stub fetch and count requests; an always-on per-project status read would add a request per project on every mount and change the default behavior the brief says must stay unchanged.

Decision

Render one Share with founder toggle per project in the tree; opening it reads the status once and offers Create/Rotate and Revoke; the issued link is composed client-side as origin + path + '#' + token, shown once in a read-only field with a Copy control, and forgotten when the control closes. The hierarchy payload is not extended with share status.

Alternatives considered

  • Include share status in the project hierarchy response — Changes the hierarchy shape every existing fixture constructs and adds capability metadata to a payload the founder route also serves.

Rationale

Additive and minimal, as the brief asks; the only new default-path DOM is one button per project, and the existing workspace suite passes without modification.

Consequences

  • Share status is read lazily; an editor must open the control to see whether a link exists.
  • The raw token exists only in the editor's page state until the control closes.

Artifacts

  • file src/components/founder-share.tsx — The share control.
  • file test/founder-share.test.tsx — Closed by default, link shown once, rotate and revoke.

D090 — Founder pages get their security headers from next.config.ts for /f/* and /api/founder/*: no referrer, no indexing, no framing, no caching; a full nonce-based CSP is deferred

2026-09-10 · build · accepted · Agent decided alone

Problem

Architecture requires founder capability pages to send no referrer and permit no indexing, and names a strict CSP with frame-ancestors 'none'. The application currently sets no response headers anywhere, and a strict script-src CSP would need per-request nonces threaded through the Next.js layout.

Decision

Add a headers() block to next.config.ts that sets Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow, noarchive, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', and Cache-Control: private, no-store, max-age=0 on /f/:path* and /api/founder/:path*; the founder page also declares robots noindex and referrer no-referrer in its metadata. The token never appears in a path or query, so no header can leak it.

Alternatives considered

  • A full strict CSP with nonces for the founder page — Requires middleware-generated nonces and layout plumbing across the whole app; out of this feature's scope and not needed for the no-referrer / no-index requirement.

Rationale

This satisfies the stated founder-page requirements with framework configuration only; the strict CSP remains a documented gap for a follow-up decision.

Consequences

  • The e2e founder spec asserts Referrer-Policy, X-Robots-Tag, and X-Frame-Options on the founder page response.
  • Other routes keep their current (header-less) posture; a site-wide CSP is a separate decision.

Artifacts

  • file next.config.ts — Founder surface headers.
  • file app/f/[publicId]/page.tsx — Metadata: noindex, no-referrer.

D091 — Disclose the tooling used after the Missions: Claude Code on mobile for the documentation closeout, and a second harness for the cross-review

2026-09-10 · wrap · accepted · Human directed

Problem

The project's records said every commit was authored through a Factory Droid session. That is no longer exactly true. Once the MVP was working, the owner did a documentation closeout and a repository-hygiene pass with Claude Code from a phone on a plane, because the network connection would not sustain a Factory session. The session log also had no top-level accounting of time: each Mission section reports agent time, which ran mostly unattended while the owner teed up a prompt and walked away, so the two measures were easy to confuse. Leaving either unsaid would make the process narrative inaccurate.

Decision

Record it plainly. The application code, capture pipeline, canvas, pins, founder links, landing page, and production deployment were built through Factory Droid Missions and Droid sessions. A documentation closeout and repository-hygiene pass were done with Claude Code on mobile, and that same harness produced the cross-review adopted in D093. docs/SESSION-LOG.md opens with a time-and-tooling ledger that keeps the human's wall-clock time apart from the agent time each session section reports.

Alternatives considered

  • Say nothing, since the MVP itself was Factory-built — Honesty about process is what the decision log is for, and the commit trailers already name the tool, so the log would be contradicted by the history it sits in.
  • Wait for connectivity and redo the closeout inside a Factory session — The work is documentation. Redoing it buys a cleaner-looking story and nothing else.
  • Sum the per-session agent minutes into one total and present it as the time spent — Agent time ran mostly unattended and overlapped with the owner's absence, so a single sum would overstate the human effort and understate the autonomy that made the build work. The ledger keeps the two measures apart.

Rationale

The human directed the disclosure, in the words quoted below. The decision costs nothing and makes the repository's own history and its narrative agree.

Consequences

  • docs/SESSION-LOG.md opens with a time-and-tooling ledger; per-session agent time stays where it was recorded.
  • Using a second harness is now a disclosed part of the process rather than an embarrassment to be tidied away; D093 makes it a deliberate technique.

Provenance

Human: “"in the interest of full disclosure, we can add a decision call-out somewhere that we used Claude Code on mobile on the plane due to network connectivity issues. I can live with that given the core MVP was otherwise working."”

Artifacts

  • file docs/SESSION-LOG.md — The time-and-tooling ledger at the top of the log.

D092 — Flag the product and architecture decisions a reviewer should read first, and surface them ahead of the full log in Markdown, the dashboard, and /reqs/decisions

2026-09-10 · wrap · accepted · Human directed

Problem

The decision log exists to explain the process, but every surface rendered all seventy-plus records in one undifferentiated column, so a reader could not tell the two dozen decisions that shaped the product from the routine build calls. Curating that list in a page component would create a second decision dataset, which the single-source rule forbids.

Decision

Add an optional `key` boolean to the decision record schema, set on the product and architecture decisions a reviewer should read first. The validator accepts it and rejects non-boolean values; docs/DECISIONS.md gains a "Key decisions" section above the index; the dashboard gains a "Key decisions only" filter chip and a per-record pill; and /reqs/decisions opens with a nav landmark listing the same records, each linking to its card in the full catalog below. Records without the flag are unchanged.

Alternatives considered

  • Keep the curated list in the page component rather than in the data — It would be a second decision dataset, which the single-source rule forbids, and the Markdown log and dashboard could not share it.
  • Also add /reqs/session-log, /reqs/next, and a third process page as a second navigation group, as the cross-review proposed — The owner declined: "Drop all three process routes." The hub stays at the five product routes, which are also the dogfood URL array and the production smoke's contract.

Rationale

The human directed the substance: the decision log reachable in the app, focused on the critical product and architecture calls. Where the flag lives — in the source data rather than a component — is mechanical inside that direction, and putting it in the data is what lets all three surfaces agree.

Consequences

  • The decision schema gains an optional `key` boolean; AGENTS.md section 2.4 documents it.
  • DOGFOOD_URLS, the hub navigation, and the production smoke's five-route list are unchanged.
  • Flagging a record is a judgment call with no test to enforce it; test/requirements-decisions.test.tsx pins only that the index matches the data and that the product-defining records stay flagged.

Provenance

Human: “yes, let's have have the human-friendly decision log accessible from the app so it's all in one. also, focus on critical product or architecture decisions to surface in that context.”

Artifacts

  • file scripts/lib/decisions.mjs — Validator and generator support for the key flag.
  • file src/components/decisions-catalog.tsx — Key-decisions index above the full catalog.
  • file docs/dashboard/index.html — Key-only filter chip and per-record pill.

D093 — Cross-review the repository with a second agent harness and adopt its findings selectively, rather than merging its branch

2026-09-10 · wrap · accepted · Human directed

Problem

An agent that reviews its own work is only checking itself. The owner ran a separate Claude Code session over the repository to audit it independently; that session pushed a branch carrying documentation reconciliation, repository hygiene, the key-decisions flag, and three new hub routes. By the time it was reviewed in Factory, main had moved on: the founder loop had merged, the landing/app split and the pin table had shipped, and main had independently reused the ids D069, D070, and D071 for different decisions. Merging the branch would have collided on those ids and reintroduced documentation asserting that the founder loop was still deferred.

Decision

Treat the branch as a review, not a patch. Adopt the findings that are still true and still valuable — the .gitignore ordering bug that made the committed .env.example unreachable, a checked-in .env.example of names only, .nvmrc pinning Node 24, moving a stray 1.5 MB PNG out of the repository root, the stale test/home.test.tsx reference and outdated layout tree in AGENTS.md, a Local setup section in the README, the time-and-tooling ledger and disclosure (D091), the honest additions to docs/NEXT.md, and the key-decisions flag (D092). Reject the three process routes at the owner's direction, reject every founder-is-deferred rewrite as now false, and renumber the branch's records instead of importing them. Fold the founder-links drafts into the log in the same pass, as D084 through D090, since nothing on main had recorded that stream at all.

Alternatives considered

  • Merge the review branch as-is and fix the fallout afterwards — Three of its four records would have collided by id with main's own D069 through D071, and six documents would have re-asserted that the founder loop was unbuilt. Cheaper to take the findings than the commits.
  • Ignore the branch and keep a single harness — The branch found four real defects nothing in the gate catches, including the .gitignore ordering bug and the stale test reference in AGENTS.md. A second harness reading the whole repository cold is a cheap audit.
  • Cherry-pick the branch's commits — Its four commits each mix adopted and rejected changes; picking them would need the same file-level resolution with less clarity about what was accepted and why.

Rationale

The owner asked for the second harness specifically to hold the work doubly accountable, so its findings deserved evaluation rather than either automatic merge or dismissal. Selecting file by file keeps the accepted changes attributable and leaves the rejections stated here instead of implied by a silent revert.

Consequences

  • The cross-review branch is not in the history; this record and the adopted changes are its trace, so the branch can be deleted.
  • docs/decisions/drafts/ is now empty and removed: the founder loop's records are D084 through D090, closing a fold-in that had been outstanding since the branch merged.
  • AGENTS.md section 5 now reflects the real layout, so the next agent reading it is not misled about where tests live.
  • Running a second harness over the repository before wrap-up is a technique worth repeating; it is not automated, and nothing schedules it.

Provenance

Human: “take a look at origin/claude/droid-project-review-2qv0o3 -- i wanted to hold us doubly accountable with another harness - and see which updates you agree with. make those changes in a branch here. then let's merge. after that we'll delete the claude-named branch.”

Artifacts

  • file .gitignore — The negation moved below the broader .env* pattern that had been re-ignoring it.
  • file .env.example — Environment names only, adopted from the cross-review.
  • file AGENTS.md — Section 5 layout tree and the corrected test references.

D094 — Defer runtime element matching with a System One model until the deterministic pre-selection has been measured

2026-09-19 · build · pending · Raised and deferred

Problem

The owner asked whether Jev, TypeSafe AI's System One model, should choose the nearest appropriate manifest element when a mark is placed. The fit is closer than a general language model's would be: Jev returns a typed choice among defined options with a confidence score, cannot hallucinate because the schema fixes the valid outputs, and costs a fraction of a cent for the eight candidates a query would carry. Two things argue against reaching for it now. The pre-selection has never been watched by a person, so there is no evidence the deterministic ranking is failing; and the cost of a wrong pick is one click, because the composer pre-selects the top candidate and offers Change and No element beside it.

Decision

Postponed, not rejected. The deferral is resolved by a number the checkpoint will produce: for each mark placed, whether the pre-selected element was the one the editor wanted. If the deterministic ranking is right nearly every time, there is nothing here to build. If it is not, the cheaper deterministic fixes come first: weight semantic kind harder so an interactive element beats the container wrapping it, prefer elements carrying an accessible name, and penalise layout containers with no text or role.

Alternatives considered

  • Adopt it now for nearest-element ranking — It would add a fourth external service and reverse a published non-goal to improve something never measured, where the current failure cost is a single click.
  • Reject runtime AI permanently — One version of the idea is genuinely out of reach of geometry: matching the comment's text to the element, so that 'this billing toggle reads the same in both states' resolves to the toggle rather than the card containing it. That is worth keeping open.

Rationale

Raised by the owner and consciously postponed in the same exchange. Recording it keeps the reasoning findable when the question returns, and names the measurement that settles it rather than leaving it to taste. Adopting it would reverse the 'no runtime AI or token-consuming product feature' non-goal published in the requirements, the walkthrough, and the live /reqs hub, so it would need a record superseding this one rather than a quiet dependency. Worth noting for whoever answers this: the valuable version re-ranks after the comment is written, which inverts today's flow, where pre-selection happens the moment a mark is dropped.

Consequences

  • The checkpoint gains one thing to record per mark: was the pre-selected element the one you wanted.
  • Until this is answered the non-goal stands, and any proposal to add a model to the product path is answered by pointing at this record.

Provenance

Human: “does it make sense to use the Jev model from typesafeai to identify the nearest appropriate DOM object to attach metadata to vis-a-vis pin position?”

Artifacts

D095 — Recover captures on the server: retryable probe and provider failures, stale attempts treated as failed, a time budget per invocation, and freed slots shared across projects

2026-09-23 · build · accepted · Agent proposed, human approved

Problem

Five capture defects shared one pattern: a recoverable condition became a dead end or waited for the daily cron. A redirect probe that could not be read (a 5-second timeout, a TLS error, a reset connection) and an origin mismatch seen inside the Browserless session after admission had approved the target were both filed as unsafe-redirect, which is non-retryable, so a network blip left a capture the product could not recover (the open question in D054). A stale attempt counted as in progress, so the project read 'Capturing…' indefinitely and the bulk retry was hidden. Every continuation ran as a nested after() inside the invocation that started it, so an eight-capture project in four waves of up to 90 seconds could outrun the 300-second maxDuration and be killed mid-chain on Vercel while passing locally, where there is no limit. A project that found both capture slots busy was never resumed by the server. And a dispatcher that lost the fenced transition could release the winner's lease.

Decision

An unreadable redirect probe fails with a new retryable outcome, target-unreachable; unsafe-redirect stays non-retryable and is reserved for redirects the policy actually refused. An execution-time origin mismatch fails as browserless-provider, which is retryable, answering D054: admission-time rejection stays final, execution-time mismatch does not. Stale attempts count as failed and retryable in project progress and join the bulk retry, and the editor's hierarchy read gives a stale newest attempt the same one automatic retry the sweep would, idempotent per attempt. Each invocation records when it started; before scheduling another capture it checks that one whole capture plus a margin still fits in CAPTURE_INVOCATION_MAX_DURATION_MS, and when it does not it hands the chain once to a fresh invocation by calling its own sweep route with the sweep secret and, where deployment protection is on, the automation bypass header. When a finished attempt's project has nothing pending, the oldest pending attempts of any project take the free slots. A rejected dispatch releases its lease only when its own transition applied.

Alternatives considered

  • Make unsafe-redirect retryable — It would weaken a deliberate policy verdict and let a retry probe the address policy repeatedly.
  • Reuse navigation-timeout or dns-failed for an unreadable probe — Both messages would misdescribe a TLS error or a reset connection to the editor.
  • A queue service for the chain — A new external dependency, already rejected in D076.
  • Stop the chain when the budget runs out, without a hand-off — Leaves the work idle until the next tab or the next day's cron, and local development has no limit to protect.
  • Leave stale recovery to the sweep — The Hobby cron runs once a day.

Rationale

Every recovery reuses a path that already existed and is already idempotent: the automatic-retry key derived from the attempt id, the fenced state transitions, and the two-slot lease cap. The hand-off falls back to today's behaviour when no origin or secret is available, so local development and tests are unchanged. The owner approved the review's recommendations as one pass rather than item by item; the specific calls inside it were the agent's, made inside that approval, and are listed here so they can be revisited.

Consequences

  • New outcome code target-unreachable; new boundary constants CAPTURE_INVOCATION_MAX_DURATION_MS (300,000) and CAPTURE_CONTINUATION_MARGIN_MS (60,000); POLICY_VERSION 2026-09-23.3 together with D098.
  • A test asserts that the four routes' maxDuration literals equal CAPTURE_INVOCATION_MAX_DURATION_MS, since route segment config has to stay a literal.
  • The hand-off needs VERCEL_URL and a sweep secret (CRON_SECRET or CAPTURE_SWEEP_SECRET), plus VERCEL_AUTOMATION_BYPASS_SECRET while deployment protection is on; without them the chain continues in place as before.
  • An execution-time mismatch now reads to the editor as a provider failure, and a page that redirects off-site with JavaScript gets one automatic retry before a person has to act.
  • GET /api/projects can write one automatic-retry row per stale attempt it notices; later reads write nothing.
  • D054 is answered and superseded by this record.

Provenance

Agent proposed: “Review of the repository before the owner's first end-to-end test: five parallel reviews (capture pipeline, canvas, founder links and security, editor journey, deploy readiness), each claim checked against the code. Recommended, in order: fix the deploy order (migrations before code, Fluid compute, CRON_SECRET); make probe timeouts and stalled captures retryable and stop long capture chains dying at maxDuration; refresh unread counts without a reload; fix composer focus after a draft moves and show handles only on the selected mark; close the login lockout, the bypass flag on Vercel, and the CSRF renewal gap; and correct the tooling disclosure.”

Human approved: “[…] I'm taking it and running with it […]”

Supersedes D054.

D096 — Canvas input: the composer keeps focus, handles only on the selected mark when they fit, one pointer and one button, exact minimums, and serialized moves

2026-09-23 · build · accepted · Agent proposed, human approved

Problem

Seven canvas defects, each confirmed in the code before it was fixed. The composer focused its textarea only on mount, so after a click moved an open draft, typing reached the canvas shortcuts: n re-placed the draft, b, c and a armed tools, and j or k stepped to another mark and could discard the draft and its comment. Every saved rectangle, circle and arrow carried resize handles at every zoom; at overview zoom on a tall page they covered small marks, swallowed pans, and saved a real resize from a one-pixel wobble. A second finger did not cancel the first, so a pinch could drop a pin. Right and middle clicks placed pins and started draws. A mark resized to the minimum could come out a rounding error short (7.999999999999999 for a box, 15.999999999999996 for an arrow) and fail server validation on every retry. A second move ending before the first save returned carried a stale revision and was lost to a false 'changed in another session'. And a click with a tool armed silently disarmed it.

Decision

The composer takes a placement counter and refocuses whenever the draft is placed or moved, and the canvas ignores single-letter shortcuts while a draft is open (Escape still works). Handles render only on the selected saved mark, and only when its shorter side, or an arrow's length, is at least 48 CSS pixels on screen; a mark keeps them until its own resize ends, and a resize released within PLACEMENT_SLOP_SCREEN_PX saves nothing. A non-primary pointerdown cancels the pending press and any gesture in progress. Only the primary button starts a press or a draw. The client snaps a minimum-size box and arrow exactly to the minimum, and the server's minimum checks allow 1e-6 of slack. Geometry saves queue per mark, sending only the latest pending geometry on the revision the previous save returned. A click with a tool armed leaves it armed.

Alternatives considered

  • Shrink handles at low zoom instead of hiding them — Smaller handles on every mark would still capture pans and wobbles.
  • Publish the 48-pixel threshold as a boundary constant — It changes only what is drawn, not what is accepted, and publishing it would force a policy-version and documentation bump for a rendering choice.
  • Round stored geometry to whole pixels — Changes the stored precision D062 fixed.
  • Retry automatically on a 409 — Would overwrite a genuine write from another session.
  • Keep click-disarms — The tool was used up with no visible result, which reads as a broken button.

Rationale

Each fix restores a rule the canvas already claimed: D074's distinction between a click and a drag, one write per settled gesture, and server validation that never rejects geometry the client itself produced. The owner approved the review's recommendations as one pass rather than item by item; the specific calls inside it were the agent's, made inside that approval, and are listed here so they can be revisited.

Consequences

  • A saved mark must be selected before it can be resized; e2e/pin-lifecycle.spec.ts selects by its badge first.
  • N no longer moves an open draft; it still places one when none is open.
  • The low-zoom handle weaknesses recorded for D079, D082 and D083 are largely fixed; a selected mark between about 48 and 72 screen pixels can still have its top-left handle over the number badge.
  • Scroll-to-zoom on a trackpad is unchanged (D058); making two-finger scroll pan is an open call for the owner.

Provenance

Agent proposed: “Review of the repository before the owner's first end-to-end test: five parallel reviews (capture pipeline, canvas, founder links and security, editor journey, deploy readiness), each claim checked against the code. Recommended, in order: fix the deploy order (migrations before code, Fluid compute, CRON_SECRET); make probe timeouts and stalled captures retryable and stop long capture chains dying at maxDuration; refresh unread counts without a reload; fix composer focus after a draft moves and show handles only on the selected mark; close the login lockout, the bypass flag on Vercel, and the CSRF renewal gap; and correct the tooling disclosure.”

Human approved: “[…] I'm taking it and running with it […]”

D097 — Both sides see replies without reloading, addresses are completed on the client, the founder arrives oriented, and link rotation asks first

2026-09-23 · build · accepted · Agent proposed, human approved

Problem

The editor's only timer was the capture poll, which stops once nothing is capturing, and the founder view had none, so a founder's reply, a status change, or an editor follow-up stayed invisible on the other side until a reload: the moment the product is named for failed silently. The address fields refused anything without https://, and the landing page parked input unchecked, so 'chickpea.co' and '/pricing' failed, the landing page's only after sign-in. A founder's own mark still read Open after they replied. Rotate and Revoke broke the link a friend already had in one click. A founder whose twelve-hour session had ended was told the link may have been replaced or turned off, when reopening it would have worked, and a founder arriving saw root/Desktop whether or not it had notes, with no word of how many notes were waiting. Thread times read like log lines, and the Markdown export dropped the conversation.

Decision

A shared visibility-aware refresh re-reads every LIVE_REFRESH_INTERVAL_MS (20 seconds) while the tab is visible and immediately on becoming visible or focused, pauses while hidden, runs one read at a time, and applies a result only if local state has not moved since the read began, so drafts, selection, typed text and the camera survive. The editor refreshes the hierarchy, the open capture's pins, the project pins and the open thread; the founder its hierarchy, pin list and thread. The client completes bare domains with https:// and resolves /path rows against the root, on blur and on submit, while the server normalizer stays strict; http:// is never added or upgraded. After a reply the founder view re-reads that capture's pins. Rotate and Revoke each take an inline confirmation. An ended session and a refused link get different messages, the first saying to reopen the link from Lucas's message. The founder view opens with a one-line summary of the notes waiting, shows per-page counts, and opens on the first capture with marks. Thread times are friendly local times with the ISO value in a time element. The project annotations read and both exports include each mark's thread.

Alternatives considered

  • Server-sent events or websockets — New infrastructure for a product with one editor and a handful of founders.
  • Keep the capture poll running — It stops by design once nothing is capturing.
  • Loosen the server normalizer — It is the security boundary for what gets captured.
  • Complete or upgrade to http:// — Would capture an address nobody typed.
  • window.confirm for Rotate and Revoke — Blocking, unstyled, and hard to test.
  • Let the editor copy the current link again — Needs the token stored recoverably, which changes the capability posture D018 and D089 set.

Rationale

It reuses the reads that already exist and the unread and status rules of D075, costs nothing while a tab is hidden, and never lets a background read overwrite something the person is doing. The owner approved the review's recommendations as one pass rather than item by item; the specific calls inside it were the agent's, made inside that approval, and are listed here so they can be revisited.

Consequences

  • More GET traffic while a tab is visible; the project annotations read now carries threads.
  • Tests that count requests hide the tab or account for the refresh; the share control's lazy read (D089) is unchanged.
  • LIVE_REFRESH_INTERVAL_MS lives in src/lib/live-refresh.ts, not in the boundary catalog.
  • A founder whose session ends while the page is open learns it on their next reply rather than immediately.

Provenance

Agent proposed: “Review of the repository before the owner's first end-to-end test: five parallel reviews (capture pipeline, canvas, founder links and security, editor journey, deploy readiness), each claim checked against the code. Recommended, in order: fix the deploy order (migrations before code, Fluid compute, CRON_SECRET); make probe timeouts and stalled captures retryable and stop long capture chains dying at maxDuration; refresh unread counts without a reload; fix composer focus after a draft moves and show handles only on the selected mark; close the login lockout, the bypass flag on Vercel, and the CSRF renewal gap; and correct the tooling disclosure.”

Human approved: “[…] I'm taking it and running with it […]”

D098 — Sign-in hardening: reserve each login attempt before checking it, per-client throttling with a global backstop, no bypass on Vercel, and CSRF renewed with the session

2026-09-23 · build · accepted · Agent proposed, human approved

Problem

Four defects in the password and session paths. PINATA_AUTH_DISABLED was honoured in any environment, so setting it in Vercel by mistake would open the editor to anyone. The login route read the throttle, checked the password, and counted a failure only afterwards, so a burst of parallel requests all passed the pre-check and a window allowed as many guesses as requests. One global bucket (D026) meant six wrong guesses every fifteen minutes from anywhere kept the owner from signing in, because a throttled pre-check refused even the right password. And renewing a session re-issued only the session cookie, so after about twelve hours of activity reads kept working while every write answered 403.

Decision

isAuthDisabled() returns false whenever VERCEL is set and logs one warning if the flag is present there; it does not consult NODE_ENV, because local live sessions run next start. Login now checks the signing secret, then a read-only throttle pre-check, then atomically reserves the attempt in two buckets before verifying the password: a per-client bucket keyed by a digest of Vercel's x-real-ip (else the first x-forwarded-for entry, else a fixed key) at LOGIN_MAX_FAILURES, and a global bucket at the new LOGIN_GLOBAL_MAX_FAILURES of 100. Either over its limit answers 429 without checking the password; success clears the client bucket and returns its slot to the global one. Every site that renews a session goes through one helper per role that writes the session and CSRF cookies together. A SESSION_SECRET shorter than 32 characters logs a warning but is not refused.

Alternatives considered

  • Key the bypass on NODE_ENV — Local live sessions deliberately run the production build with the flag.
  • Keep only the global bucket — It lets anyone lock the editor out.
  • Per-client buckets only — Does not bound guessing spread across many addresses, D026's original concern.
  • Refuse a short SESSION_SECRET — Could lock the owner out of production if the existing secret is short.
  • Fix each renewal site separately — Fourteen sites would drift again; one helper and a source scan keep them together.

Rationale

Each change is the smallest one that makes its guarantee hold under concurrency and on a real deployment, and D026's protection against distributed guessing is kept with a ceiling one guesser cannot reach. The owner approved the review's recommendations as one pass rather than item by item; the specific calls inside it were the agent's, made inside that approval, and are listed here so they can be revisited.

Consequences

  • New boundary constant LOGIN_GLOBAL_MAX_FAILURES (100 per LOGIN_WINDOW_MS); LOGIN_MAX_FAILURES is now per client; POLICY_VERSION 2026-09-23.3 together with D095.
  • Off Vercel the client address can be spoofed, but every attempt still counts against the global bucket.
  • Racing throttled attempts can over-count slightly within a window; they never reach the password check.
  • A source-scan test fails if anything outside the cookie modules, login, or the founder exchange builds a session cookie directly.
  • D026's single shared bucket is superseded by this record; its fail-closed secret checks carry over unchanged.

Provenance

Agent proposed: “Review of the repository before the owner's first end-to-end test: five parallel reviews (capture pipeline, canvas, founder links and security, editor journey, deploy readiness), each claim checked against the code. Recommended, in order: fix the deploy order (migrations before code, Fluid compute, CRON_SECRET); make probe timeouts and stalled captures retryable and stop long capture chains dying at maxDuration; refresh unread counts without a reload; fix composer focus after a draft moves and show handles only on the selected mark; close the login lockout, the bypass flag on Vercel, and the CSRF renewal gap; and correct the tooling disclosure.”

Human approved: “[…] I'm taking it and running with it […]”

Supersedes D026.

D099 — Put the migrate-then-deploy order and the Vercel settings in the runbook, serve founders from a custom domain, and disclose the Cursor commits

2026-09-23 · build · accepted · Agent proposed, human approved

Problem

Production had been on commit 523dcd9 since 2026-09-10 while main moved about forty commits ahead, and the runbook went straight from pushing to deploying. Migrations are not run by the build, so pushing main before migrating would break project creation, pins and the founder view on their first read. The four capture routes export maxDuration = 300, which Hobby accepts only with Fluid compute; CRON_SECRET was missing from .env.example, and without it the sweep answers 404. Deployment protection is all_except_custom_domains (D068), so every vercel.app address, production included, shows a Vercel sign-in wall to a founder, and the editor, signed in to Vercel, would never notice; because the share control builds the link from the page's origin (D089), a link issued on a vercel.app address points at the wall. Separately, the session log's tooling ledger cited D079 and D081 where it meant D091 and D093, and eight commits authored 'Cursor Agent' on pull requests 3, 5 and 6 appeared in no disclosure.

Decision

The README runbook now checks the Vercel project first (Fluid compute on, the seven Production variables present including CRON_SECRET, neither local-only flag set, the bypass for automation present, and whether recent pushes deployed at all), then validates, then migrates the shared database, listing applied migrations first because Drizzle skips one older than the newest recorded, then deploys, verifies, and rolls back without reversing additive migrations. A new README section says founders cannot reach a vercel.app address and that production needs a custom domain the editor also works from. .env.example names CRON_SECRET and CAPTURE_SWEEP_SECRET. The tooling ledger names the Claude Code and Cursor exceptions, cites the right records, and adds the Cursor work and this pass.

Alternatives considered

  • Run migrations in the Vercel build — A build that migrates the shared production database on every preview deployment is worse than a manual step in the right order.
  • Turn deployment protection off — Would expose every preview deployment, and their per-commit URLs, as well as production.
  • Leave the Cursor commits to the git history — The log exists partly to explain how AI tools were used, and anyone reading the history would find them before the ledger did.

Rationale

These are the steps that stood between main and a first real test, and the gaps a reviewer would find first. Writing them down in the order they must happen removes the one ordering mistake that breaks production outright. The owner approved the review's recommendations as one pass rather than item by item; the specific calls inside it were the agent's, made inside that approval, and are listed here so they can be revisited.

Consequences

  • The owner's pre-test checklist: Fluid compute, CRON_SECRET, a custom domain, migrate, then deploy.
  • Work after 2026-09-23 is recorded the same way.

Provenance

Agent proposed: “Review of the repository before the owner's first end-to-end test: five parallel reviews (capture pipeline, canvas, founder links and security, editor journey, deploy readiness), each claim checked against the code. Recommended, in order: fix the deploy order (migrations before code, Fluid compute, CRON_SECRET); make probe timeouts and stalled captures retryable and stop long capture chains dying at maxDuration; refresh unread counts without a reload; fix composer focus after a draft moves and show handles only on the selected mark; close the login lockout, the bypass flag on Vercel, and the CSRF renewal gap; and correct the tooling disclosure.”

Human approved: “[…] I'm taking it and running with it […]”

D100 — Serve production at yourpinata.dev, and correct what D099 assumed about the deployment

2026-09-23 · build · accepted · Human directed

Problem

D099 was written from the repository alone, without access to the Vercel project or the database, and took three facts from older records that were no longer true. It said production had been on commit 523dcd9 since 2026-09-10; in fact pushes to main had been deploying to production all along, the last before this pass five days earlier. It said migrations 0004 to 0006 stood between main and production; the migration table showed all seven applied, 0004 to 0006 on 2026-09-12. And it said deployment protection was all_except_custom_domains (D068), so founders would meet a Vercel sign-in page; the project's ssoProtection was in fact off, and every vercel.app address answered publicly. Two things D099 named were genuinely missing: CRON_SECRET in Production, and a custom domain.

Decision

Attach yourpinata.dev, registered through Vercel Domains with Vercel's nameservers, to the pinata project as the production domain, with www.yourpinata.dev as a 308 redirect to it. Add CRON_SECRET to Production as a sensitive variable generated from 32 random bytes and never displayed, then redeploy the current production deployment so functions read it. Leave deployment protection off, as the owner had set it, and leave D099's runbook order and disclosure standing; correct its factual premises here and in the README, milestones, and next steps.

Alternatives considered

  • Turn deployment protection back on now — The owner had turned it off and is the only user; with the custom domain in place it can be re-enabled later without breaking founder links, since all_except_custom_domains leaves the custom domain open.
  • Run db:migrate anyway — The migration table already listed every migration in the repository; there was nothing to apply.
  • Rewrite D099 in place — The log records what was believed and when; the correction belongs in a new record that points at it.

Rationale

The owner asked for the domain and for the remaining deploy steps to be carried out once a Vercel CLI was available. Checking the live project and database before acting showed which of D099's steps were real and which rested on stale records, which is the lesson worth keeping: verify deployment state against the platform, not against the decision log.

Consequences

  • Production: https://yourpinata.dev, commit b80f982, Fluid compute on, CRON_SECRET set; the sweep route now answers 401 without its secret rather than 404.
  • Founder links are issued from yourpinata.dev; the vercel.app addresses serve the same deployment.
  • The production smoke still asserts an SSO wall that no longer exists; that one check fails until the smoke follows the current posture (NEXT.md).
  • Preview deployments are public and share Production's secrets and database, guarded by the editor password alone.
  • Four provider variables are stored as non-sensitive in Vercel; re-adding them as sensitive is listed in NEXT.md.

Provenance

Human: “use the Vercel CLI (if possible) to use the new yourpinata.dev domain I purchased with Vercel Domains / cool. we're local now. we have the vercel cli installed. run through all of the above.”

Artifacts

D101 — Raise the per-client editor login failure limit from 5 to 10, keeping the global backstop at 100

2026-09-25 · validate · accepted · Human directed

Problem

On the first turn of the milestone-2/3 live checkpoint the owner was locked out of editor sign-in after five failed attempts and told to wait thirteen minutes. The failures were mechanical rather than hostile: the agent had copied EDITOR_PASSWORD out of .env.local with its surrounding double quotes still attached, so several attempts could not have succeeded. D098 had already replaced D026's single shared bucket with a per-client bucket plus a global backstop, which stops one noisy client locking the editor out — but the per-client key falls back to a fixed key when there is no x-real-ip or x-forwarded-for, which is exactly the local checkpoint case. So the limit that stopped the owner is still five, and a solo operator running the checkpoint on 127.0.0.1 meets it as easily as before.

Decision

Raise LOGIN_MAX_FAILURES from 5 to 10. LOGIN_GLOBAL_MAX_FAILURES stays at 100, LOGIN_WINDOW_MS stays at 900,000 ms, and D098's reserve-then-verify ordering, two-bucket accounting, 429 answer, and success-clears-the-client-bucket behaviour are all untouched. POLICY_VERSION moves to 2026-09-25.1.

Alternatives considered

  • Remove the brute-force blocker entirely — What the owner first asked for. The agent raised the exposure — one shared password is the only control in front of the editor account on a live deployment — and the owner narrowed the request to the failure count in the same breath.
  • Raise the per-client limit to 20 and the global ceiling to 200 to match — The agent tried exactly this first and the gate refused it: test/boundaries.test.ts asserts LOGIN_GLOBAL_MAX_FAILURES >= 10 * LOGIN_MAX_FAILURES so that one client can never exhaust the backstop. Keeping the ratio by doubling the global ceiling would have doubled the distributed-guessing budget to buy headroom nobody asked for. Ten is the largest per-client value the published invariant allows without touching the global ceiling.
  • Leave it at five and clear the bucket whenever the checkpoint trips it — The agent had already done this once by hand. It costs nothing but leaves the defect in the product and makes the agent a required participant in the owner signing in.
  • Give the local fixed-key fallback its own higher limit — It would put an environment-shaped exception inside a published boundary, and the published catalog is deliberately one value per policy. If local ergonomics and deployed exposure ever really diverge, that is a boundary change with its own record, not a hidden branch.

Rationale

Ten per client per fifteen minutes is still a hard ceiling against an eleven-character secret, and the control that actually matters against a distributed guesser — the global backstop of 100 — does not move at all. What changes is only how quickly a single well-meaning operator can lock themselves out, which is the whole of the observed problem. Ten is also the largest value D098's published invariant permits while leaving the global ceiling alone, so the change takes the available headroom rather than inventing new exposure. Every test imports LOGIN_MAX_FAILURES rather than hardcoding five, so no assertion weakens.

Consequences

  • POLICY_VERSION is 2026-09-25.1; docs/EVALS.md and docs/ARCHITECTURE.md republish the value and the drift test keeps code, docs, and the rendered /reqs routes pinned together.
  • A single client now gets ten guesses per window instead of five; ten such clients still exactly exhaust the unchanged global backstop, which is the behaviour D098 intended.
  • D098's per-client key still degenerates to a fixed key off Vercel, so local runs share one bucket; this record raises the limit rather than changing that key.
  • The per-client limit is now at the ceiling of D098's 10x invariant, so any future raise has to move LOGIN_GLOBAL_MAX_FAILURES with it and argue for the added exposure.

Provenance

Human: “raise the failure count. i'm not stressed here yet”

D102 — Take design direction from paper.design: a warm off-white sheet, near-black type in light weights, hairline rules, and one accent kept for the marks

2026-09-29 · build · accepted · Human directed

Problem

Pinata looked like a first build: a cream-and-brown palette with a red that also colored links, the wordmark, headings, selected tools and badges; a rounded system font; and buttons and inputs left almost entirely to the browser default, so every control looked slightly different. The owner asked for the landing page and the application to take their direction from paper.design, a design tool whose own site is a quiet, near-monochrome sheet with large light type.

Decision

Adopt Paper's system as direction, not as a copy. Paper's site was read from its published HTML and stylesheets (live screenshots were not possible from the build environment). Tokens: a warm off-white sheet (--bg #fcfcf9), white cards, a cream for anything raised (#f3f2eb), near-black ink (#141414), a neutral gray for secondary text (#666666, 5.59:1), a lighter gray only for the second tone of large headlines (#868682, 3.56:1), hairline rules (0.5px on high-density screens), 4/8/12px radii, and blue focus rings (#2d6fcd). The accent becomes #c9381a, the brand spark deepened until it clears 4.5:1 on every surface it sits on, and is reserved for the marks, badges, unread counts and errors; links, the wordmark, headings and selected controls go to ink. Type is Inter, self-hosted as one variable file under the SIL Open Font License, so the in-between weights Paper uses (360 for large headlines, 480 for controls, 550 for emphasis) are available with no third-party request. Buttons and inputs get one base style at zero specificity, so every existing component rule still wins: cream secondary buttons, solid-ink primary buttons, a press that scales to 97%. The landing gains a quiet top bar, a two-tone headline (the name in ink, the promise in gray at the same size), sections introduced by a plain lowercase line and a large light heading, a three-point 'how it works', and a footer. Inside the app, rail and list navigation read as rows rather than stacked buttons, selected tools and toggles are solid ink, capture cards are white paper cards, and the founder view's uppercase badge becomes a quiet lowercase line.

Alternatives considered

  • Use Paper's own typeface, Matter — It is a commercial typeface licensed to Paper; copying its file would not be ours to ship. Inter is what Paper's own interface uses beside it and covers the same weights.
  • Load a font from Google Fonts or a CDN — The landing contract forbids third-party requests (VAL-LANDING-001, asserted in e2e/landing.spec.ts).
  • Drop the accent and go fully monochrome, as Paper's site does — Pins sit on arbitrary screenshots and have to be found at a glance; one warm accent, used only for marks and their signals, is what lets the interface stay quiet around them.
  • Add a dark theme in the same pass — The brand mark is ink on a light tile and would vanish on a dark one, and the canvas needs its own review against dark chrome; better as its own change than a side effect of this one.
  • Lowercase the copy itself everywhere — Lowercasing is applied where Paper's voice lives (the landing's lines, the top bar, the footer, section openers) and left alone in working labels, whose exact names the tests and screen readers rely on.

Rationale

Almost every color in the stylesheet already went through a custom property, so new token values carried the direction through the editor, the canvas panels, the founder view, the requirements hub and the walkthrough at once, and the few structural changes could concentrate on the landing. Every text pairing was measured against WCAG AA before it was chosen, and the tests that pin the tokens (visual-tokens, walkthrough-slides, brand-mark) now pin the new values.

Consequences

  • public/fonts/ carries inter-latin-wght-normal.woff2 (Inter 5.3, 48 KB) and its licence; the brand mark's tile, app/icon.svg, and the walkthrough theme move to the new surface and palette together, as their tests require.
  • The requirements hub's current-page tab is solid ink rather than red; test/visual-tokens.test.ts still checks surface text on the accent, which pin badges use.
  • test/canvas/canvas-layout.test.ts now asserts the pressed camera mode by its ink fill rather than a red outline.
  • The composer's Save and a thread's Send are primary (solid ink) buttons.
  • Dark mode is not built; NEXT.md records it.

Provenance

Human: “Update the Pinata root/landing page and the app itself to take design direction from paper.design”

Artifacts

D103 — Take project creation off the public landing, and make the example a readable captured page carrying one mark of each kind

2026-09-29 · build · accepted · Human directed

Problem

The public landing opened with a working capture form. An anonymous visitor could type addresses that were parked in the tab until they signed in (D067), so the first thing a stranger saw was a tool only the editor can use, and the page's top spent its space on a form rather than on showing what Pinata does. The static example below it was a gray wireframe with two pins: it showed that pins exist but not what a page looks like marked up, and none of the other three mark kinds appeared anywhere a visitor would see them.

Decision

The landing takes no address. Its hero ends in a call to sign in ('Sign in to start a review', a link to the sign-in form) beside a quiet link to the example; creating a project happens only on /pins/new, behind sign-in, and sign-in always lands on /pins. The anonymous entry form, the tab-scoped draft, and sign-in's draft routing are removed. The example becomes the hero's picture, placed directly under it: a readable captured pricing page (nav, headline, a monthly/annual toggle, three plan cards with prices, features, and buttons, a 'Most popular' tag) drawn in the captured site's own green palette so it reads as someone else's page, with one numbered mark of each kind drawn the way the canvas draws them — pin 1 on the toggle (the selected mark, with its thread and DOM context), circle 2 around a price that doesn't say per what, arrow 3 from 'Most popular' to the Pro plan, box 4 around the Pro card's call to action — and the panel lists all four by the product's own names.

Alternatives considered

  • Keep the form but collapse it behind a button — It keeps a path that only the editor can finish in front of every visitor, and keeps the draft-parking machinery alive for it.
  • Keep the example as a wireframe and add the other mark kinds — Marks on gray blocks don't show what they point at; the value is seeing a mark sit on a real button or price.
  • Use a real screenshot of Chickpea as the example — Chickpea captures are internal test and demo material, not reusable marketing assets (REQUIREMENTS, Evaluation targets), and a raster would not scale crisply or stay in step with the marks' geometry.
  • Draw the example page in Pinata's own colors — The marks would blend into the page; the captured site in its own palette is what makes the marks read as the feedback layer.

Rationale

Only the editor can create a project, so the public page's job is to show the product and point to sign-in. The example now carries the whole vocabulary on a page a visitor can read, using the same mark geometry shapes, badge placement, and names the product uses, so it teaches the product truthfully rather than approximately.

Consequences

  • Removed: src/components/capture-entry-form.tsx, src/lib/capture-draft.ts, and their tests; sign-in always routes to /pins; /pins/new renders the form directly.
  • REQUIREMENTS item 9 and the EVALS landing scenarios describe the sign-in call and the four-mark example; the landing e2e drives sign-in from the call and creates the project on /pins/new.
  • The example fixture (src/lib/example-capture.ts) holds marks of every kind with their real geometry shapes and a shared page layout; its badges carry data-mark-number and data-mark-kind.
  • D067's parked-entry handoff is superseded by this record; D066's landing stands with its capture-entry clause replaced by the call to sign in.

Provenance

Human: “Let's move the actual project creation behind the authorization wall so that the landing page doesn't actually have that part up top. Let's also see if we can improve upon the static graphic that's included.”

Artifacts

  • file src/components/example-capture.tsx — The example page and its four marks.
  • file src/components/landing.tsx — The landing, with the call to sign in.

Supersedes D067.

D104 — A dark theme that follows the device, with a toggle that remembers the visitor's choice

2026-09-30 · build · accepted · Human directed

Problem

Pinata had one theme. D102 left dark mode out because the brand mark, the canvas, and a handful of hard-coded colors each needed attention first, and Paper, the design direction, has a dark sheet of its own. The owner asked for a dark mode toggle.

Decision

Add a dark theme as a second set of token values, Paper's dark sheet as direction: page #181818, cards #1f1f1e, raised #2a2a27, cream ink #efefe4, gray #a8a8a2, a large-text gray #7c7c77, the accent lifted to #ff6b47 so it reads as text on dark and still carries dark text on pin badges, Paper's soft blue #81adec for focus, and dark-appropriate decision status colors. With no saved choice the page follows prefers-color-scheme; a toggle (a moon or sun icon button named 'Dark mode' whose pressed state says whether dark is showing) saves an explicit choice in localStorage. A small script in <head> copies the saved choice onto <html data-theme> before the first paint, following Next's 'preventing flash before hydration' guide, with suppressHydrationWarning on <html>. The toggle sits in the landing's top bar, the editor bar, the requirements hub header, and the founder view. The primary button's hover and the decision status colors became tokens so they flip with the theme; color-scheme makes native controls and scrollbars follow.

Alternatives considered

  • Follow the device only, with no toggle — The owner asked for a toggle, and a founder reading on a bright laptop at night should be able to choose.
  • Store the choice in a cookie and render the theme on the server — Reading a cookie in the root layout opts every page out of static prerendering; the inline script gets the same no-flash result without it.
  • A three-state control (light, dark, device) — More to explain for little gain; clearing site data returns to the device setting, and one pressed-or-not button is simpler to read.
  • Darken the landing example's captured page too — It depicts someone else's light website, exactly as a real screenshot would look; darkening it would misrepresent what a capture is.

Rationale

Nearly every color already went through a custom property (D102), so a dark theme is a second value set rather than a second stylesheet. Every dark text pairing was measured before it was chosen and is locked by the same contrast suite as the light theme, and the public surfaces pass the same axe sweep in dark.

Consequences

  • src/lib/theme.ts holds the storage key, the <head> script, and the apply/read helpers; src/components/theme-toggle.tsx is the button.
  • The dark values are written twice (device preference, saved choice); test/visual-tokens.test.ts asserts the two copies agree and every dark pairing clears AA.
  • e2e/theme.spec.ts runs in CI: the toggle switches and remembers across a reload and to /reqs, a device that prefers dark gets dark with no click, and /, /reqs, and /reqs/decisions pass axe in dark.
  • Stays light on purpose: the landing example's captured page, real screenshots, the brand mark's white tile, and the walkthrough video's palette.

Provenance

Human: “add a dark mode toggle”

Artifacts

  • file src/lib/theme.ts — Storage key, boot script, and helpers.
  • file src/components/theme-toggle.tsx — The toggle.

D105 — Give the canvas the window: a collapsible project sidebar, a fixed documentation footer, and a canvas as tall as the window allows

2026-09-30 · build · accepted · Human directed

Problem

The editor spent width and height on things that are not the canvas. D070 made the project rail a native disclosure, but collapsing it only folded the list vertically: the column kept its full 16–22rem width, so the canvas gained nothing. The documentation links took a whole row under the workspace, and the canvas was capped at 76% of the window height. The owner asked for the rail to collapse as a sidebar and the links to become a fixed footer, to maximize the editing area.

Decision

The rail becomes a sidebar that collapses as a whole: a 'Hide projects' chevron in its header shrinks the column to a 2.25rem strip holding 'Show projects', and the canvas column takes the rest. Both buttons carry aria-expanded and aria-controls. The choice is remembered in the browser (localStorage 'pinata:sidebar'), read after mount so the server and first client render agree; the per-project disclosures inside stay session-only as D070 set. The old root disclosure is removed so there is one way to collapse, not two. The open rail is sticky and scrolls on its own; on a phone, where it stacks above the canvas, it sits in the flow. The documentation links move into a thin fixed footer (2.5rem, hairline top, one line that scrolls sideways on a phone), and the page pads by its height. The canvas grows to min(100dvh − 11rem, 72rem), still bounded.

Alternatives considered

  • Keep the vertical disclosure and only shrink the column — A column that stays even partly wide still steals width; the strip is the smallest thing that still offers the way back.
  • An overlay drawer for the rail — It would cover the capture while open, which is when the editor is moving between pages and wants to see where they land.
  • Do not remember the collapsed state (as D070 did for the disclosures) — Sidebar width is a layout preference like the theme; making the editor collapse it again on every load defeats the point.
  • Drop the documentation links from the editor — They are part of the product's public record (D092) and a one-line footer costs 2.5rem.

Rationale

Each change returns space to the canvas without removing anything: the rail is one click away, the links are still on every editor screen, and the canvas stays bounded so the page itself never scrolls for it.

Consequences

  • The rail's root <details class="tree-root"> is gone; e2e/canvas-session.ts clicks 'Show projects' when the sidebar is collapsed instead of opening that disclosure.
  • test/project-workspace.test.tsx covers hide, show, the saved choice, and restoring a saved collapsed sidebar; test/canvas/canvas-layout.test.ts pins the new bounded canvas height.
  • --footer-height is a token; the sticky rail's max height and the editor page's bottom padding follow it.

Provenance

Human: “the yellow box should be collapsable as a side bar. i thought we already talked about this the red box should be a fixed footer. we're looking to maximize the edit/canvas area”

Artifacts

  • file src/components/project-workspace.tsx — The collapsible sidebar.
  • file src/components/editor-home.tsx — The fixed footer.

D106 — The project rail becomes a drawer that overlays the canvas, so opening it never shifts the layout

2026-09-30 · build · accepted · Human directed

Problem

D105's collapsible sidebar took a grid column when open and a strip when collapsed, so every toggle reflowed the canvas and the panel sideways: the capture under the editor's cursor moved each time the rail opened or closed. The owner asked for the left navigation to be an overlay that sits on top of the canvas instead.

Decision

The workspace grid keeps one fixed 2.25rem strip for the toggle and never changes. The rail is a drawer that hangs from a sticky dock in that strip and overlays the canvas: a surface card with a shadow, above a faint veil that takes an outside click so it never reaches the canvas. It starts closed and closes itself when a page or an overview is chosen, on Escape, or on a click on the veil; focus moves to its 'Hide projects' control on open and back to 'Show projects' on close. Being transient, it is no longer remembered in the browser. On a phone the dock sits in the flow above the canvas and the drawer still overlays.

Alternatives considered

  • Keep D105's column with a slide animation — An animated reflow is still a reflow; the capture still moves under the cursor.
  • Overlay with no veil — A click outside would also reach the canvas and could place a mark while the editor only meant to dismiss the drawer.
  • Keep remembering the open state — An overlay remembered open would cover the canvas on every load; a drawer that closes after each choice has nothing worth remembering.
  • Start open — The workspace opens on the project overview, whose grid is itself a way into the captures; the drawer is for switching projects, one click away.

Rationale

The fixed strip is what removes the shift: nothing the drawer does changes a grid track, which the checks confirmed (the canvas column's box is identical with the drawer open and closed). Closing on a choice keeps the overlay out of the way of the work it serves.

Consequences

  • Replaces D105's collapsible column and its remembered state; D105's fixed footer and taller canvas stand.
  • Tests reach the rail the way a reader does, through openRail helpers (test/helpers/rail.ts, e2e/rail.ts); the credentialed specs reopen the drawer after each page choice.
  • test/project-workspace.test.tsx covers the closed start, focus in and out, closing on a choice, on Escape, and on an outside click, and that the grid never changes.

Provenance

Human: “left's change the left-hand nav to an overlay that sits on top of the canvas, instead of putting at the same z-index and pushing the content left/right with layout shifting”

Artifacts

  • file src/components/project-workspace.tsx — The project drawer.

D107 — Bring the piñata character into the public homepage with a coordinated illustration set

2026-09-30 · design · accepted · Agent proposed, human approved

Problem

The public homepage kept the brand character in its small header logo; long example metadata made the page feel text-heavy and left a large gap beside the capture.

Decision

Use the approved image-generation assets for a split hero, three illustrated process steps, a pencil mascot beside sign-in, a resting footer mascot, and a decorative burst from the asset sheet. Keep the example capture and comment thread visible and move its DOM context and mark list into a native details disclosure. Scope the layout to the anonymous root page. Use local Next Image assets, preload only the hero, reserve intrinsic dimensions, and keep pale paper illustration mats legible in both themes.

Alternatives considered

  • Repeat the small header logo without a full illustration system — Would not explain the process visually or provide the personality the owner approved.
  • Keep all example metadata expanded beside the screenshot — The long panel dominates the demonstration and creates empty space under the capture.

Rationale

The owner approved the paper-cut visual direction and generated assets, then explicitly requested implementation. An isolated checkout of current origin/main preserves the unrelated unfinished design checkout. Existing authentication, capture behavior, and editor styling remain in their established components.

Consequences

  • Seven self-hosted PNG assets and their original generation prompts are retained.
  • The public landing introduces capture, annotation, and sharing before showing the working example.
  • Technical example details remain keyboard-accessible through native HTML disclosure without adding a client component.
  • Landing tests open the disclosure before checking context and verify illustration loading and narrow-screen overflow.
  • After disclosure of unrelated credentialed editor-suite failures, the owner explicitly directed a pull request and merge when CI is green. This release uses that instruction in place of the default direct-to-main workflow and complete local gate requirement.

Provenance

Agent proposed: “The direction is set: playful paper-cut illustrations, the existing piñata character, and restrained orange accents. Start with the hero and three process illustrations, then carry that same style into the sign-in area and footer.”

Human approved: “okay, this is all amazing! can you go ahead and impliment it on the pinata root page for us?”

Artifacts

  • file src/components/landing.tsx — Illustrated public homepage.
  • file docs/brand/homepage-illustration-prompts.json — Prompts for the approved generated artwork.
  • screenshot screenshots/D107-homepage-desktop.png — Illustrated homepage at desktop width.
  • screenshot screenshots/D107-homepage-mobile.png — Illustrated homepage at mobile width.

D108 — Archive a project so it leaves the project list, as a separate column rather than a delete

2026-10-01 · build · accepted · Human directed

Problem

Every project the editor has ever made sits in the left-hand drawer, so the list grows with each friend's site and the ones still in play get harder to find. There was no way to put a finished project away short of deleting it, and the schema's deletedAt is a tombstone that every read treats as gone.

Decision

Projects gain a nullable archived_at column (migration 0007). POST /api/projects/[publicId]/archive stamps it, behind the standard editor mutation boundary (same origin, session, CSRF proof); archiving twice keeps the first timestamp, and a missing or deleted project is the generic 404. The editor's project list (GET /api/projects, listProjectHierarchies) leaves archived projects out. Nothing else changes: the single-project read, pages, captures, pins, threads, and the founder link all behave as before.

Alternatives considered

  • Reuse deletedAt — deletedAt is a tombstone that every read, the founder link, and capture assets treat as gone; an archive is meant to come back (D109), and conflating the two would make a later unarchive indistinguishable from undeleting.
  • Hide projects in the browser only — The list would differ per browser and per device, and the next sign-in elsewhere would bring the clutter back.
  • A status enum on projects (active / archived) — A timestamp answers the same question and also records when, in the same shape as deletedAt and shareRevokedAt.

Rationale

One nullable column and one filter is the smallest change that takes a project out of the drawer while keeping it whole, so undoing it later is a matter of clearing that column.

Consequences

  • drizzle/0007_project_archive.sql adds projects.archived_at; it must be applied to the deployed database (npm run db:migrate) before the archive route is used there.
  • Stale-capture recovery runs off the project list, so an archived project's stuck captures wait until it is unarchived.
  • Archiving the last project leaves the existing empty-list message, 'No projects yet.'
  • test/server/project-archive.test.ts covers the store function, the list filter, the founder link, and the route boundary; test/archive-project.test.tsx covers the control.

Provenance

Human: “we need to make a way to archive a project. the left-hand nav gets littered fast. we can save for later a means to unarchive.”

Artifacts

  • file src/lib/server/projects/archive.ts — The archive store function.
  • file app/api/projects/[publicId]/archive/route.ts — The editor-only archive route.
  • file drizzle/0007_project_archive.sql — The migration adding projects.archived_at.

D109 — Defer a way to unarchive a project

2026-10-01 · build · superseded · Raised and deferred

Problem

Archiving (D108) takes a project out of the list. Without a way back, an archive made by mistake can only be undone in the database.

Decision

Postponed by the owner in the same request that asked for archiving. The data is shaped for it: unarchiving is clearing projects.archived_at, and DELETE on /api/projects/[publicId]/archive is the natural route for it (it answers 405 until then). The interface needs a place to list archived projects to choose from.

Alternatives considered

  • Build unarchive alongside archive — The owner asked to save it for later; the immediate problem is a cluttered drawer.

Rationale

Recorded so the open question stays visible, and so the archive control's confirmation wording (nothing is lost) has a record to point at.

Consequences

  • Until this is answered, the archive control asks once before acting, because there is no button to undo it.
  • Answering it means a new record that supersedes this one, not an edit to it.

Provenance

Human: “we need to make a way to archive a project. the left-hand nav gets littered fast. we can save for later a means to unarchive.”

Superseded by D143.

D110 — Archive lives in the selected project's header, and an archived project's founder link keeps working

2026-10-01 · build · accepted · Agent proposed, human approved

Problem

Two choices inside D108: where the Archive control goes, and whether archiving should also end the founder's access.

Decision

An Archive button sits at the right end of the selected project's title row, beside the founder-link control, with the same inline Confirm / Cancel step that Rotate and Revoke use. Its confirmation says the project leaves the list and that pins, threads, and the founder link stay as they are. Archiving does not touch the founder capability: a founder holding the link can still open the project and reply.

Alternatives considered

  • An Archive button inside each project's entry in the drawer — Puts a destructive-looking control in the list it is meant to tidy, and next to the Overview and page buttons a reader clicks most.
  • Archiving also revokes the founder link — Revoke already exists as its own action; folding it into archive would make a tidy-up end a founder's access, and an unarchive (D109) could not bring the same link back.

Rationale

The header is where the other project-level actions already live, and keeping archive to the editor's own list makes it safe to do without thinking about who else holds a link.

Consequences

  • src/components/archive-project.tsx is the control; the workspace re-reads the list after an archive and falls back to the first remaining project.
  • To also end a founder's access, the editor uses Revoke before or after archiving.

Provenance

Agent proposed: “Where should the Archive button live? Project header (Recommended): next to "Share with founder" on the open project — or inside each rail entry. What happens to an archived project's founder link? Keep it working (Recommended): archive only tidies your nav — or turn it off too.”

Human approved: “"Where should the Archive button live?"="Project header (Recommended)", "What happens to an archived project's founder link?"="Keep it working (Recommended)"”

Artifacts

  • file src/components/archive-project.tsx — The Archive control with its inline confirmation.

D111 — Canvas controls float over the canvas as one compact toolbar

2026-10-02 · build · accepted · Human directed

Problem

Above the canvas sat a full-width hint line, a "Desktop — URL" heading, and two rows of text buttons (three fit modes, zoom out, zoom in, the zoom readout, then three draw tools), plus a row of text buttons for the overview and pin stepping. Together they took about 230px of height before the screenshot began. The owner also remembered an open request to move the canvas controls into overlays like Figma's or Paper's; no record of it existed, so this record is that request.

Decision

One floating toolbar sits over the bottom centre of the canvas: icon buttons for box, circle and arrow; zoom out; a "19% ▾" button that opens a small popup with Entire page, Fit width and Natural size; zoom in; and a "?" that opens the verb line which used to sit above the canvas. Icon buttons show their name and key in a tip on hover and keyboard focus, are solid ink while armed (D102), and take the shared blue focus ring. The row above the canvas becomes one compact line: a ‹ Overview button, the device toggle, the page address as a quiet caption (still the capture's h3, by the same name), and up/down buttons with "Pin 2 · 2 of 4". The canvas takes the height back (100dvh − 16rem).

Alternatives considered

  • Keep the rows and shrink the buttons — Saves little height and keeps the hint and heading lines the owner called space-consuming.
  • A vertical tool rail down the left edge of the canvas — Would sit next to the project drawer's button (D106); one bottom pill keeps every canvas control in one place.
  • A pin (or select) tool that is pressed when nothing else is armed — The tools arm one drag and then disarm (D074, D079); a standing pin tool would read as a mode the canvas does not have. A click on the page still drops a pin, and "?" says so.

Rationale

The owner asked for modern patterns: icons, clear interactive states, and dropdowns. Fit modes are used rarely, so they move behind the zoom readout as in Figma; the draw tools and zoom stay one click away. Every button keeps its accessible name as aria-label, so assistive tech and the tests address the same controls as before. The toolbar sits outside the canvas region's pointer handlers, so a press on it never drops a pin, and an open popup puts a clear veil over the canvas so an outside click closes it without reaching the canvas (as D106's drawer does). No dependency was added: the icons are inline SVG in the style of the drawer's chevron.

Consequences

  • The canvas starts about 225px higher on a laptop screen, and the toolbar is always in view above the fixed footer.
  • Escape closes an open popup before anything else sees the key.
  • Unit and end-to-end tests reach a fit mode through small helpers that open the popup first (test/helpers/camera.ts, e2e/camera.ts), as openRail does for D106.
  • The founder view's read-only canvas gets the same pill with zoom only; its own hint is unchanged.
  • Phone width: the control row wraps to two short lines and the pill fits inside the canvas.

Provenance

Human: “this whole section is WAY toos pace consumptive. use modern UX/UI best practices to clean this up. consider icongraphy, interactive states, drop-downs/etc.”

Artifacts

  • file src/components/capture-canvas.tsx — The floating toolbar.
  • file src/components/canvas-icons.tsx — The toolbar icons.
  • screenshot screenshots/D111-before-light.png — Before: rows of controls above the canvas (light).
  • screenshot screenshots/D111-after-light.png — After: one control row and the floating toolbar (light).
  • screenshot screenshots/D111-before-dark.png — Before (dark).
  • screenshot screenshots/D111-after-dark.png — After (dark).
  • screenshot screenshots/D111-zoom-menu-light.png — The fit modes in the zoom popup.
  • screenshot screenshots/D111-tooltip-dark.png — A tool's tip with its key (dark).
  • screenshot screenshots/D111-before-phone.png — Before at phone width.
  • screenshot screenshots/D111-after-phone.png — After at phone width.

D112 — The capture panel collapses to a strip, and data keeps writing

2026-10-02 · build · accepted · Human directed

Problem

The right-hand panel (Selection, pins, Capture, Details) always took 17rem beside the canvas.

Decision

A toggle in the panel's top-right corner collapses it to a thin strip holding the toggle; the canvas widens to fill the space (this one reflows on purpose, unlike D106's drawer). Collapsing only hides the panel's content with the hidden attribute; the component stays mounted. The choice is remembered per browser in localStorage, read after mount, with every access in try/catch. A mark selected while collapsed shows as its number badge on the strip instead of forcing the panel open.

Alternatives considered

  • Open the panel automatically when a mark is selected — Undoes the owner's choice on every click; the badge says what is selected without taking the space back.
  • Unmount the panel while collapsed — "Even when collapsed, data will write": hiding must not change what the workspace does, so the panel stays mounted.

Rationale

The owner wants more room for the canvas and asked that collapsing never stop writes. All writes, seen-marking and live refresh live in the workspace, not the panel, and a test drops and saves a pin while the panel is collapsed.

Consequences

  • Collapsed state survives reloads in the same browser; private windows and blocked storage simply open the panel.
  • On narrow screens the panel still stacks below the canvas; collapsed, it is one short row.

Provenance

Human: “lets make this collapsable, and when collapsed the canvas gets more viewport space. even when collapsed, data will write.”

Artifacts

  • file src/components/capture-panel.tsx — The collapsible panel.
  • screenshot screenshots/D112-collapsed-light.png — Collapsed: the strip with the selected pin's badge, and the wider canvas (light).
  • screenshot screenshots/D112-collapsed-dark.png — Collapsed (dark).

D113 — Pin list rows in parts, and one spacing rhythm in the panel

2026-10-02 · build · accepted · Human directed

Problem

Each pin in the panel list read as one run-on line ("Pin 1 · “comment” · element text…") in the same color, and the last pin sat flush against the "Capture" heading.

Decision

Each row is in parts: a number badge in the accent color matching the canvas marker (quiet grey once resolved, squared for a box), the comment as the main line clamped to two lines, and the element as a smaller, dimmer line clamped to one. A resolved row says so, and unread replies show as a small "2 new" pill (D075). Rows are split by a hairline; hover and the selected fill are unchanged. Every heading in the panel gets the same space above it. The two .pin-list rule blocks are merged into one.

Alternatives considered

  • Keep one line and only change colors — Still a wall of text: the number, comment and element need separate lines to scan.

Rationale

The badge ties a row to its marker on the canvas at a glance; the comment is what the editor reads first. Each row's accessible name stays the mark's full label (D078), so screen readers and tests hear the same thing as before.

Consequences

  • Row text is clamped by CSS only; the full comment is in the selection view and the table (D115).

Provenance

Human: “let's make the pin line items a little more diffentiated. it's just a wall of white text.”

Artifacts

  • file app/globals.css — The .pin-list and .pin-row rules.
  • screenshot screenshots/D113-list-light.png — The list in parts (light).
  • screenshot screenshots/D113-list-dark.png — The list in parts (dark).

D114 — The selected pin's actions fit on one line

2026-10-02 · build · accepted · Human directed

Problem

In the 17rem panel, "Edit comment", "Delete pin" and "Resolve pin" each wrapped onto two lines and the last button spilled past the panel's edge.

Decision

The buttons show short words, "Resolve" (or "Reopen"), "Edit" and "Delete", and never wrap a label; a narrow panel wraps the row instead. Each keeps its full name ("Resolve pin", "Edit comment", "Delete box") as its accessible name, which begins with the visible word. Resolve comes first, in the solid primary style, as the usual next step; the edit and delete confirmations follow the same pattern.

Alternatives considered

  • Icon-only actions — Delete and resolve are consequential; a word is clearer than an icon here.

Rationale

The owner pointed at the wrapped buttons in a screenshot. Short labels fit the panel at its width in both themes without widening it.

Consequences

  • While a change is saving, the button shows and announces "Saving…".

Provenance

Human: “issue here ... see buttons”

Artifacts

  • file src/components/capture-panel.tsx — The action row.
  • screenshot screenshots/D111-after-light.png — The action row on one line, in the panel at right.

D115 — Pin text is never cut in the table, the Markdown, or the selection

2026-10-02 · build · accepted · Human directed

Problem

The pin table's Pin column, the copied Markdown's headings, and the panel's selection title all used the mark's short name (D078), which cuts the comment at 60 characters and the element at 40 with an ellipsis. The full comment was in the table, but in the last column, off the side of a wide table.

Decision

The table's Pin cell shows only the number badge and "Pin 1", and the whole comment moves to the column right after it (Page and Device stay first, D077). The Markdown heading is just "## Pin 1"; the element is on its own detail line and the comment is quoted in full under the details, with its replies after it (D097). The panel's selection title is the badge and "Pin 2"; the comment and element follow in full beneath it, and the pin-step line reads "Pin 2 · 2 of 4".

Alternatives considered

  • Raise the excerpt limits — Any limit still cuts some comment; the full text is already available, so show that instead.
  • Put the full comment in the Markdown heading — A comment can run several lines, and a heading cannot.

Rationale

The owner saw truncated pin text in the view and in the Markdown and called it out. The short name stays the accessible name of the list and table buttons, where brevity helps, but nothing visible or copied is cut.

Consequences

  • Pasted Markdown headings are shorter; the comment is still verbatim in the quote below them.
  • The founder view, left with the short name here, is brought in line by D117.

Provenance

Human: “the pin copy is truncated in the view and in the markdown”

Artifacts

  • file src/components/pin-table.tsx — The table's Pin and Comment columns.
  • file src/lib/pin-export.ts — The Markdown heading.
  • screenshot screenshots/D115-table-before-dark.png — Before: the Pin column repeats a cut-down comment.
  • screenshot screenshots/D115-table-after-dark.png — After: "Pin 1" and the whole comment beside it.

D116 — Letting go of a selected pin: a × in the panel, and Escape

2026-10-02 · build · accepted · Human directed

Problem

Once a pin was selected there was no visible way to deselect it. Clicking empty canvas drops a new pin, and the only way out was clicking the same pin again in the list.

Decision

The panel's Selection heading carries a × "Clear selection" button while something is selected. Escape also lets go of the selection, as the last step after its existing order (a draw in progress, an armed tool, an open draft), and never while typing. An Escape that closes an open toolbar popup or the project drawer is used up there and does not also deselect.

Alternatives considered

  • Deselect on a click on empty canvas — A click on the page drops a pin (D074); making it deselect instead would change the main gesture.

Rationale

The owner could not find how to dismiss a selection. A visible control where the selection is shown, plus the key people already try, covers both pointer and keyboard.

Consequences

  • The panel's keyboard list says "Escape cancels, then lets go of the selected mark".
  • Letting go is not a write.

Provenance

Human: “i also cna't figure out how to dismiss the focus on a single pin”

Artifacts

  • file src/components/capture-canvas.tsx — The Escape order.
  • file src/components/capture-panel.tsx — The Clear selection button.

D117 — The founder view shows pin text whole, and its page list gets its column back

2026-10-02 · build · accepted · Human directed

Problem

The founder's pin list and selection title still used the mark's short name (D078), which cuts the comment and the element with an ellipsis (left as a known gap by D115). Checking the founder page also showed its page list crushed into a 2.25rem column: D106 changed the shared .workspace grid for the editor's drawer toggle, and the founder page uses the same grid with a real list in that column.

Decision

The founder's list uses the same rows in parts as the editor's (D113), from one shared component, showing every status (Open, Replied, Resolved) since the founder acts on Open. The founder's selection title is the badge and "Pin 2", with "Points at: “…”" giving the element's text in full; the comment opens the thread below as before. The 2.25rem drawer strip moves to a .workspace-drawer modifier the editor alone uses, and .workspace returns to its 16–22rem first column for the founder.

Alternatives considered

  • Show the founder only the title, with no element line — The short name was the founder's one hint at what a mark points at; the full text keeps it without internals (no tag, no path).

Rationale

The owner asked for the founder view to match. One shared row component keeps the two lists from drifting apart. The grid regression came from D106 and was live in production; scoping the drawer grid to the editor restores the founder layout without touching the editor's.

Consequences

  • Each founder row's accessible name is unchanged (the short name and its status), so assistive tech and the tests address the same entries.
  • The list's element line uses the element's full text and is clipped by CSS only; the 40-character cut no longer applies anywhere a row shows it.

Provenance

Human: “yes, watch it and fix the founder view too”

Artifacts

  • file src/components/pin-row.tsx — The shared row.
  • file src/components/founder-view.tsx — The founder list and selection.
  • screenshot screenshots/D117-founder-light.png — The founder view: rows in parts, the page list in its column, and the selection title (light).
  • screenshot screenshots/D117-founder-dark.png — The founder view (dark).

D118 — Interactive patterns from Vercel's Geist: menu focus, tooltip timing, key caps

2026-10-02 · build · accepted · Agent decided alone

Problem

The owner invited using Vercel's design library and its interactive element patterns where they help ("if there's anything to be gained from using vercel's design library and interactive element patterns feel free"). The floating toolbar's popups (D111) left focus on their button, so a keyboard user had to Tab around, and focus fell to the page when a popup closed. Tooltips appeared instantly on hover, so sliding along the toolbar flashed every tip.

Decision

Adopt three Geist patterns without adding a dependency: the fit-mode popup behaves like a menu (focus moves to the pressed mode on open; arrow keys, Home and End move between modes; Escape or a choice returns focus to the button that opened it, and an outside press closes it without moving focus), and the help popup takes focus on open and returns it on Escape; tooltips show after a 400ms hover pause but at once on keyboard focus; shortcuts in tooltips are drawn as small key caps, as Geist's Kbd does.

Alternatives considered

  • Add Geist's React components — New dependencies are blocked by scripts/lib/approved-deps.mjs, and the components would bring their own styling alongside the Paper direction (D102).
  • Switch the type to the Geist font — The Inter type is part of the Paper direction the owner chose (D102); a font change is a design decision for the owner, not a pattern.

Rationale

These are the patterns that change how the controls feel in use, especially from the keyboard, and they fit in a few dozen lines of the existing component and stylesheet. The rest of Geist's value is its visual language, which the owner already set differently.

Consequences

  • The fit modes stay plain buttons with aria-pressed (not ARIA menu items), so their names and states are unchanged for tests and assistive tech.
  • Tooltip delay is CSS only; the tip fades rather than moves, so reduced-motion settings are unaffected.

Artifacts

  • file src/components/capture-canvas.tsx — Popup focus handling and arrow keys.
  • screenshot screenshots/D118-tooltip-light.png — A tooltip with its key cap.

D119 — A halftone wash in the piñata orange on the public landing

2026-10-02 · design · accepted · Human directed

Problem

The public landing's background was one flat colour: near-black in dark mode, which the owner found hard to look at, and plain cream in light. The piñata's orange appeared only in small accents and inside the illustrations.

Decision

Two decorative halftone washes in the accent orange: one behind the hero, strongest at the right edge beside the illustration and fading out before the sections below, with a faint second source low on the left; one inside the sign-in card, a halo around the mascot in the card's middle column (where #17 moved it), clear of the copy beside it; on a phone, around the mascot between the copy and the sign-in form. Dots grow where the wash is strong and vanish where it fades, as print halftone does, over a soft glow of the same orange (an ember on black, a warm tint on cream). Both are pure CSS: a grid of soft dots added to a large gradient and thresholded with a contrast filter, then coloured through blend modes so the ink is always the accent token. The top bar stays clear; on a phone the hero wash moves down behind the illustration, clear of the paragraph; forced-colors (high contrast) mode hides both.

Alternatives considered

  • A generated SVG or PNG halftone — A file of thousands of dots costs bytes and cannot follow the theme tokens or the window width; the CSS version costs nothing to download and adapts to both.
  • Change the dark background to a different solid colour — Still one flat field; the owner asked for shading with a second colour, and the dot texture echoes the pins and the print-like paper style (D102).

Rationale

The owner asked for a second colour from the piñata palette, as dot-matrix gradient shading, paired with black or white, taking inspiration from a post on drawing patterns with code (generated SVG mosaics and backgrounds that cost less than an image). The accent orange is the piñata's own colour; orange on black in dark mode and orange on cream in light pair it with black and white as asked. The landing must make no third-party requests, which a CSS-only pattern keeps.

Consequences

  • The landing's sections are positioned so they paint above the washes; the washes span the window without adding horizontal scroll (checked at 1440px and 390px).
  • The washes are aria-hidden, empty, and ignore the pointer.
  • The wash's group paints no background of its own (the orange is a layer inside it), so automated contrast checks measure the text against the page colour it actually sits on; a first version with a solid orange group background made axe judge every hero line against flat orange.
  • PR #17 moved the mascot out of the copy column into its own middle column before this landed; the card wash was re-aimed from the copy's lower left (where the dots would have run under the text) to a halo around the mascot.

Provenance

Human: “take a quick look at the root for pinata (pre-sign in). i think the background needs at least one other color. solid black is really hard to look at. what about introducing some dot matrix gradient shading - maybe using one of the non-b&w pinata colors + either black or white to complement? maybe take some inspiration from this LI post: https://www.linkedin.com/pulse/think-bigger-maksim-surguy-rkdrc/”

Artifacts

  • file app/globals.css — The .landing-halftone rules.
  • screenshot screenshots/D119-before-dark.png — Before: the dark landing, one flat field.
  • screenshot screenshots/D119-after-dark.png — After: the hero wash in dark.
  • screenshot screenshots/D119-after-light.png — After: the hero wash in light.
  • screenshot screenshots/D119-after-phone-dark.png — After: on a phone, behind the illustration.
  • screenshot screenshots/D119-card-dark.png — The sign-in card's wash (dark).
  • screenshot screenshots/D119-card-light.png — The sign-in card's wash (light).
  • screenshot screenshots/D119-card-phone-dark.png — The sign-in card's halo on a phone (dark).

D120 — Hover a mark to read it, and see the element it points at

2026-10-04 · design · accepted · Human directed

Problem

Reading a note on the canvas meant clicking its mark, which also changed the selection and the panel; and nothing on the screenshot showed which element a note was about, only the element's text in the panel. A screen recording of Paper's comments showed both solved: a preview card on hover, and a dotted outline around the commented element.

Decision

Hovering a saved mark (pin, box, circle, or arrow) shows a small dark card beside it after a short pause: the mark's badge and title, its status and unread replies, the whole comment, and what it points at. Never during a draft, a draw, a drag, or with a toolbar popup open, and not for the mark the panel already shows. While a mark is hovered, or else while one is selected, the element it is attached to is outlined with a dashed line in the focus blue; a draft's nearby-candidate highlight keeps its orange fill so the two never read alike. Small polish alongside: saved markers lift slightly under the pointer, and the canvas behind the screenshot has a faint dot grid. Both views get it: the editor and the founder's page.

Alternatives considered

  • Avatar markers instead of numbers, as Paper uses — "Pin 3" is how notes are referenced in the table and the Markdown; the numbers stay.
  • Make the card a keyboard-reachable dialog — The panel and the list already give the same details to the keyboard and assistive tech; the card is a pointer shortcut and is hidden from assistive tech.

Rationale

The owner picked these two from the video review ("3 and 4 are spot on") and asked for slight visual and UX improvements alongside. The card reuses the draft composer's placement, which keeps a popover inside the canvas; the outline reuses the existing highlight node with a second variant. No dependency was added.

Consequences

  • The canvas takes the full saved marks as a prop (details) for the card and the outline; without it, neither appears.
  • The card waits about a quarter second before showing, so sweeping across a busy capture does not flash every note; with reduced motion it shows without the fade.

Provenance

Human: “3 and 4 are spot on. We should also consider slight visual/UX improvements we can tweak to make Pinata more visually appealing as well.”

Artifacts

  • file src/components/capture-canvas.tsx — The hover card and the highlight choice.
  • screenshot screenshots/D120-hover-light.png — Hovering pin 4: the card beside it, and the dot grid (light).
  • screenshot screenshots/D120-hover-dark.png — The same in dark.
  • screenshot screenshots/D120-zoomed-light.png — Zoomed in: the dashed outline around the element pin 4 points at.
  • screenshot screenshots/D120-zoomed-dark.png — Zoomed in (dark).

D121 — An agent link: a secret, read-only brief of the open marks

2026-10-04 · build · accepted · Human directed

Problem

Handing feedback to a coding agent meant copying the Markdown export and pasting screenshots in one at a time. The owner wanted an easier way to pass a project to an agent, without managing keys or accounts yet.

Decision

Each project can have an agent link, created from a new "Agent link" control beside "Share with founder". It is a secret URL, /a/<token>, that serves a live Markdown brief: a short "what to do" section, then one section per screenshot with a direct link to the image (/a/<token>/captures/<id>, no sign-in), then every open mark as the copy export writes it (status, position, element, path, the whole note, its replies). Resolved marks are left out and counted. Creating the link shows it once, with a one-line prompt to paste into an agent; Rotate and Revoke each ask first. It is stored like the founder link (only a SHA-256 digest, a version, a revoke time) in its own columns, so the two links never affect each other.

Alternatives considered

  • An MCP endpoint (or WebMCP) as the interface — More useful once agents should also reply or resolve marks, which needs write access and its own checks. A plain Markdown URL works in every agent today; an MCP endpoint can sit on the same link later.
  • Put the token in the URL fragment, as the founder link does — An agent fetches a plain URL and never sees a fragment. Instead the link only reads, every answer says no-store, noindex, and no-referrer, and it can be rotated or revoked at any time.
  • Reuse the founder link — Handing an agent the founder's link would mean revoking the founder to stop the agent. Separate links keep each revocable on its own.

Rationale

The owner asked for something easier than Markdown copy and image paste, with no key management ("I just want to avoid key management for now"), and approved the agent link ("go ahead and build the agent link - great idea!"). It reuses the founder link's token scheme, the project pin read, the Markdown export, and the screenshot delivery code, and adds no dependency.

Consequences

  • Migration 0008 adds three columns to projects. It must run on the production database (npm run db:migrate) before this deploys, or every project read fails on the missing columns.
  • The token travels in the path, so it can appear in the hosting provider's request logs. Rotate the link if a log is ever shared.
  • The brief's screenshot links use the Host header the agent called, the same way the same-origin check reads it.

Provenance

Human: “go ahead and build the agent link - great idea!”

Artifacts

  • file src/lib/agent-brief.ts — The brief's Markdown.
  • file src/lib/server/agent/link.ts — Create, rotate, revoke, and look up the link.
  • screenshot screenshots/D121-agent-link-light.png — A new agent link with its ready-to-paste prompt (local test link, light).
  • screenshot screenshots/D121-agent-link-dark.png — The same in dark.
  • screenshot screenshots/D121-agent-link-phone.png — Phone width.

D122 — The project's link controls as pills with anchored cards

2026-10-05 · design · superseded · Human directed

Problem

"Share with founder" and "Agent link" each took a full row of plain buttons with their state in loose text, and opening one pushed a wide box of stacked labels, fields, and same-weight buttons into the page. The owner called it very clunky.

Decision

Both controls are now pills on one row under the project title: an icon, the name, and a caret, joined to a status dot and a short state (Off, Active · v4, Revoked). Opening one shows a card anchored under its pill, with a title and a close button, one line saying what the link is for or what state it is in, the new link in a field with its copy button attached, the agent prompt in a box whose footer holds the one emphasized button (Copy prompt), and Rotate and Revoke as quiet actions at the bottom, Revoke in the accent. Confirming a revoke turns the step into a tinted box with a solid red button. Escape or the close button closes the card and returns focus to the pill. On a phone, the card drops into the page at full width instead of floating.

Alternatives considered

  • Keep the inline panels and only tighten spacing — The clunkiness came from the layout itself: two full-width rows, and every action at the same weight.
  • Close the card on any click outside it — A new link is shown only once; a stray click would lose it before it was copied.

Rationale

The owner asked for it directly ("let's update the sytles for this ... it's very clunky"). The pill-and-card pattern matches the canvas toolbar's popups (D111) and the icons come from the same set. Behavior, accessible names, and the one-time display are unchanged; only the short state words changed, so three tests that read them were updated. No dependency was added.

Consequences

  • The always-visible state is now the short form (Off / Active · vN / Revoked); the panel no longer repeats it as a sentence.
  • The card floats over the captures below it on wide screens, so it carries a shadow and sits above them.

Provenance

Human: “also, let's update the sytles for this ... it's very clunky.”

Artifacts

  • screenshot screenshots/D122-before-dark.png — Before: the screenshot the owner shared.
  • screenshot screenshots/D122-closed-light.png — After, closed: both links as pills on one row.
  • screenshot screenshots/D122-agent-light.png — A new agent link, with the prompt and its copy button (light).
  • screenshot screenshots/D122-agent-dark.png — The same in dark.
  • screenshot screenshots/D122-confirm-light.png — Confirming a revoke.
  • screenshot screenshots/D122-founder-dark.png — The founder link before one exists (dark).
  • screenshot screenshots/D122-agent-phone.png — Phone width: the card sits in the page.

Superseded by D146.

D123 — The landing page's sign-in as one dialog

2026-10-06 · design · accepted · Human directed

Problem

PR #14 (from v0) moved the landing sign-in into a modal, but it was built on an older landing page, its CI was failing, and it turned the whole landing into a client component. Since the redesign (D119), the sign-in form sat inside the invitation card.

Decision

Every sign-in call on the landing page (the top bar, the hero button, a new button in the invitation card where the form was, and the footer) opens one shared dialog with the password form. It is the browser's own dialog opened as a modal: the page behind is inert, focus moves to the password field and back to the button that opened it, and Escape, the close button, or a click outside closes it. A link to /#editor-login still opens it. The landing page stays a server component; only the dialog and its buttons run in the browser.

Alternatives considered

  • Merge PR #14 as it was — Its CI was failing, it no longer matched the landing page, and it made the whole page client-side.
  • A hand-built overlay with a focus trap — The native dialog already traps focus, handles Escape, and makes the page behind inert.

Rationale

The owner asked to merge PR #14 and close it out ("merege and close", then "go ahead and do PR 14"). This keeps its intent and replaces its code.

Consequences

  • PR #14 is closed as replaced by this change.
  • The test DOM has no showModal(), so the dialog falls back to the open attribute there.

Provenance

Human: “go ahead and do PR 14.”

Artifacts

  • file src/components/sign-in-dialog.tsx — The shared dialog and its buttons.

D124 — Signing in again without losing work

2026-10-06 · build · accepted · Human directed

Problem

A tab left open past the end of the editor session still showed its screenshots and pins, but every new request was refused. Dropping a pin or drawing a box said only that nearby elements could not be loaded, and saving said it could not be saved, so it looked like a breaking change.

Decision

While the editor is open, any request to the editor's own API that comes back 401 opens a "Sign in again" dialog in place. The page is not reloaded, so the draft and its comment stay. Signing in sends the refused request again with the new session and CSRF proof, so the pin or box that failed to save simply saves; several refused requests wait on one sign-in. Closing the dialog lets them fail as before, draft still on screen. Because a new mark asks for nearby elements right away, an expired session shows up when the mark is dropped, before any typing. Separately, when nearby elements fail to load for any other reason the draft offers Try again, and the keyboard hint under the Save and Cancel buttons is gone.

Alternatives considered

  • Save the draft to browser storage and send the editor to the sign-in page — More moving parts, and the editor loses its place; signing in on the page keeps everything as it was.
  • Check the session before every edit — An extra request each time; the first refused request already tells us, at the same moment.

Rationale

The owner confirmed the failures came from an expired sign-in and asked for the simple best practice: stop the work from going on silently, keep the change, and make them sign in again when it saves.

Consequences

  • The dialog wraps window.fetch on the editor pages (/pins and /pins/new) for as long as they are open; sign-in, founder, and other-site requests are never caught.
  • A retried save reuses its idempotency key, so a request that did reach the server is never saved twice.

Provenance

Human: “Let's make sure that if the user tries to edit but the auth session has expired, we don't let them keep going, or we let them make one change, save it to local cache, and then, when it attempts to save, force them to sign in again. Whatever the best practice is there, that is very simple.”

Artifacts

  • file src/components/session-guard.tsx — The sign-in-again dialog and the request retry.
  • screenshot screenshots/D124-sign-in-again.png — Dropping a pin after the session ended: sign in again, the draft stays behind it.

D125 — A box or circle can be attached to several elements

2026-10-06 · build · accepted · Human directed

Problem

Some notes are about the space between elements, like the gap between the hero headline and the shirt image, and the fix may need margin or padding on both. A mark could be attached to only one element, so the other side of the gap was never named for the agent or developer reading the note.

Decision

A box or circle draft lists its nearby elements as checkboxes: the first one ticked is the main element, and up to four more can be added; No element clears them all. The chip reads, for example, "2 elements · “Good clothes for…”". The server checks every extra id against the capture's own manifest and stores their snapshots in a new also_elements_json column (migration 0009); pins and arrows keep a single element. Hovering or selecting the mark outlines every attached element on the screenshot, and the details panel, pin table, hover card, Markdown export, and agent brief list each one with its path. While testing it, a long-standing bug surfaced and was fixed: the draft panel did not reposition when its element list opened, so the first click in the list moved the panel and missed.

Alternatives considered

  • Allow several elements on every kind of mark — A pin and an arrow point at one spot by nature; the ask was boxes and circles, which cover an area.
  • Store all elements in the existing element_snapshot_json column as a list — Every reader of that column expects one snapshot; a separate column keeps old marks and old code unchanged.

Rationale

The owner asked for it directly: "can we make it so that the box / circle selectors allow for multi-select for near elements (optionally)? in this case, i want to call out space between dom objects, and it might requre margin/padding changes to both". No dependency was added.

Consequences

  • Migration 0009 adds also_elements_json to annotations; it must run on the production database (npm run db:migrate) before this deploys.
  • An idempotency key's digest includes the extra ids only when there are some, so keys recorded before this still replay.
  • The draft panel now follows its own size with a ResizeObserver, which also fixes the missed first click in a pin's single-choice list.

Provenance

Human: “can we make it so that the box / circle selectors allow for multi-select for near elements (optionally)? in this case, i want to call out space between dom objects, and it might requre margin/padding changes to both”

Artifacts

  • file src/components/pin-composer.tsx — The checkbox list and the chip.
  • screenshot screenshots/D125-composer-light.png — A box over a gap, attached to the headline and the shirt image (light).
  • screenshot screenshots/D125-composer-dark.png — The same in dark.
  • screenshot screenshots/D125-saved-light.png — The saved box: details list both elements, both outlined (light).
  • screenshot screenshots/D125-saved-dark.png — The same in dark.

D126 — The landing example uses the app's pin table; New project moves into the projects menu

2026-10-07 · design · accepted · Human directed

Problem

The landing page's "Explore the details behind each pin" showed a two-column layout (one mark's DOM context beside a numbered list) that looked rougher than the pin table in the app. Separately, "New project" sat in the editor's top bar, away from the list of projects it adds to.

Decision

The landing details now render the app's own PinTable, filled from the example fixture: page, device, badge, the whole comment, status, position, and element with its path, and a working "Copy all as Markdown". A mark with a founder reply in its thread reads Replied. In the editor, "New project" moves from the top bar to the top of the projects menu, just under its header. With no projects there is no menu, so the empty state's "Create your first project" stays the way in; when the project list fails to load, a "New project" link sits beside Try again.

Alternatives considered

  • Restyle the old two-column layout — A second look-alike would drift from the real table; reusing it keeps the landing truthful to the product.
  • Keep New project in the top bar as well — The owner asked to move it, and two routes to the same form is clutter.

Rationale

The owner asked for both: "in the app this table looks cleaner than it does on the sign-in page. please udpaet." and "let's move the \"new project\" button into this menu." No dependency was added.

Consequences

  • The landing example now has buttons (row selection and Copy), so the landing test's "no controls" check narrowed to its intent: no fields, and buttons only inside the table.
  • End-to-end specs reach the project form through a helper that opens the menu, or uses the empty state on a fresh database.

Provenance

Human: “in the app this table looks cleaner than it does on the sign-in page. please udpaet.”

Artifacts

  • file src/components/example-pin-table.tsx — The fixture fed into the app's table.
  • screenshot screenshots/D126-landing-table-light.png — The landing details as the app's pin table (light).
  • screenshot screenshots/D126-landing-table-dark.png — The same in dark.
  • screenshot screenshots/D126-new-project-menu-light.png — New project at the top of the projects menu (light, sample projects).
  • screenshot screenshots/D126-new-project-menu-dark.png — The same in dark.

D127 — Sentence case, "Pinata" capitalized, and a demo video on the landing

2026-10-07 · design · accepted · Human directed

Problem

Feedback left on yourpinata.dev itself, read through the agent link (D121): links, buttons, headings, and footer text were lowercase, several forced by CSS; the brand appeared as "pinata" in running text; and the example showed a still image of a finished review rather than the experience of making one.

Decision

Every visible label on the landing is in sentence case, and the CSS that forced lowercase on the top links, section eyebrows, footer headings, and the founder page's badge is gone. "Pinata" is capitalized everywhere except the logo's wordmark: the hero heading, page titles, the logo's spoken name, the footer, and the walkthrough heading. The landing example now plays an eighteen-second looping demo, drawn in the browser with Remotion from the same fixture as the table below it: a cursor picks each tool and adds a pin, a circle, an arrow, and a box, each comment types in, the link is sent, and the founder's reply lands on pin 1. It is muted and has play and pause controls; with reduced motion, or before script loads, the static example shows instead. The walkthrough's annotate slide and transcript, which still said circles and arrows were to come, now describe all four mark kinds.

Alternatives considered

  • Render the demo to an MP4 file — A video file in the repo goes stale when the example changes; drawing it from the fixture keeps the demo, the static example, and the table in step, and the player is already a dependency (D072).
  • Keep lowercase as a brand style — The owner asked for sentence case across the board; only the logo stays lowercase.

Rationale

The owner left five marks on yourpinata.dev and handed them over through the agent link: "We need consistent casing for links/buttons. Let's go with sentence casing across the board.", "Pinata as a brand should be capitalized. only in the logo will it be call lower case.", "Let's sentence case all of this. not a fan of all lowercase.", "let's re-render this to include square and circles as well. in face, let's use remotion and render a simple walkthrough demo video of the experience here.", and "more sentence casing, please". No dependency was added.

Consequences

  • The demo paints with the page's CSS, so it plays in the browser player; it is not registered for the MP4 render, where those styles do not exist.
  • Tests and e2e specs that read the old lowercase names now read the capitalized ones.

Provenance

Human: “We need consistent casing for links/buttons. Let's go with sentence casing across the board.”

Artifacts

  • file remotion/demo/PinataDemo.tsx — The demo composition.
  • file src/components/example-demo.tsx — The player, with the static fallback.
  • screenshot screenshots/D127-demo-frames.png — Four moments: pin, circle, arrow, box (light).
  • screenshot screenshots/D127-demo-reply-dark.png — The link sent and the founder's reply (dark).
  • screenshot screenshots/D127-demo-phone.png — Phone width, with the sentence-cased headings.

D128 — The pin panel as one accordion; the projects menu as a switcher

2026-10-07 · design · accepted · Human directed

Problem

The pin panel showed the selected pin above the list, so picking another pin changed everything above the list and the row moved out from under the pointer; the comment appeared three times; fixed help text sat between the reader and the other pins. In the projects menu, clicking a name only opened or closed it, going to the project was a separate Overview button, pages were a third kind of button, the current-project bar and the open state were unrelated, and the page count looked like a feedback count.

Decision

Pin panel: one list where every mark is a row and the selected one opens in place — its status, the comment once, the element, Resolve / Edit / Delete, then its replies, which scroll inside the row starting at the latest, with the reply box behind a Reply button. Resolved marks gather in a closed "N resolved" group that opens when its mark is selected. The drag hints left the panel (the canvas help covers moving); the delete confirmation says the number is retired; page and device moved into Details. Projects menu (proposal B): a flat list of projects, each with its open-pins count; a click anywhere on the row shows that project's overview and closes the menu. Each project's addresses sit behind a small (i) beside its name, shown on hover or keyboard focus, so no URL takes room in the menu. A project's pages are picked in the project: its overview cards, or a page picker above the canvas (shown when there is more than one page). The project title in the header is now the project's heading. Along the way: signing out is a full page load (it no longer reopens the sign-in dialog from a cached /#editor-login), and several end-to-end specs that CI skips were brought up to date.

Alternatives considered

  • Proposal A: a file tree where the arrow opens and the name goes — The owner chose B: no nesting at all, and the menu answers only which project.
  • Let a long thread grow the whole row — The owner chose scrolling inside the row so the rows below stay close.

Rationale

The owner asked to rethink both: "the Pin menu behavior is really weird. it's hard to navigate. should we try an accordian wher ethe active pin takes up the bulk of the space, but you can still easily get to the others?" and "it's unclear what opens/closes the project entry vs. jumping into the entry itself. please revisit and come up with two proposals." Then chose: resolved pins in a closed group ("yes"), scrolling inside the row ("let's do this"), and "go with Proposal B. this sounds right." No dependency was added. On seeing the old menu live, the owner added: "let's move the URL into an onHover (i) icon next to the project name. it's cuombersome in the menu."

Consequences

  • Reaching a page in a different project takes two clicks: the project, then the page.
  • The founder's view is unchanged; the thread view's new options are opt-in.

Provenance

Human: “go with Proposal B. this sounds right.”

Artifacts

  • file src/components/capture-panel.tsx — The pin accordion.
  • screenshot screenshots/D128-panel-light.png — A pin open in place, its thread scrolled to the latest reply (light, sample data).
  • screenshot screenshots/D128-panel-dark.png — The same in dark.
  • screenshot screenshots/D128-panel-resolved-light.png — The resolved group opened.
  • screenshot screenshots/D128-menu-light.png — The projects menu as a switcher (light).
  • screenshot screenshots/D128-menu-dark.png — The same in dark.
  • screenshot screenshots/D128-toolbar-light.png — The page picker above the canvas.
  • screenshot screenshots/D128-menu-info-light.png — A project's addresses behind its (i) (light).
  • screenshot screenshots/D128-menu-info-dark.png — The same in dark.

D129 — Add pages to an existing project

2026-10-07 · build · accepted · Human directed

Problem

A project's pages were fixed when it was created. The only place to enter addresses was the New project form, the server had no way to add a page to a project that already existed, and the overview had no control for it. The one related control, the scoped retry under a screenshot, did take a fresh screenshot of a page that had worked, but it was labelled "Retry", so it read as a failure-only action.

Decision

The project overview ends with an "Add pages" card, dashed like "+ New project" in the projects menu, which says how much room the project has left. Pressing it opens a form in its place across the row: URL rows like the New project form's, finished the same way (a bare domain gains https://, a path like /pricing resolves against the project's root, D097). POST /api/projects/[publicId]/pages adds the rows behind the same boundary as creation (same origin, session and CSRF proof, content type and byte cap, strict schema, per-row URL corrections by index with nothing echoed) and commits, in one transaction, the idempotency record, the new pages after the existing ones, and a pending Desktop and Mobile attempt for each, then drives them after the response (D076). An address the project already has is skipped and reported by row, never added twice; if every row is already a page, nothing is written and the form stays open with a note on each row. The project's limits hold across additions: 16 unique pages and 64 screenshots in all. An archived or deleted project takes no pages. Under a screenshot that worked, the scoped retry now reads "Take a new Desktop screenshot" (or Mobile); after a failure it still reads "Retry … capture".

Alternatives considered

  • An "Add pages" button beside Share with founder and Agent link — Those two are the project's links; adding pages belongs with the page cards it adds to, and the owner agreed with the card.
  • Report an address the project already has as an error on its row — Asking for a page the project has is not a mistake; skipping it and saying so lets the rest of the rows go through.
  • Reuse the New project form, with the root and title hidden — Its reordering, root, and title controls do not apply; a short form with only URL rows is clearer, and it shares the address messages and the completion rule.

Rationale

The owner asked: "We should have a means to add new images to an existing project. Am I missing that somewhere? Otherwise, we should definitely have an \"Add new\" screen or something like that, since we support multiple URLs." The proposal was a card at the end of the overview's page cards that opens a form in place, the server route mirroring creation, the limits shown on the card, already-present addresses skipped with a note, and the Retry rename. The owner answered: "i agree with you UX strategy. let's do it." No dependency and no database change: the pages and captures tables already allow more pages per project, and the unique page index is what refuses a second copy of an address.

Consequences

  • A project can grow after it is shared; the founder view shows the new pages once their screenshots are ready, with no extra step.
  • Two tabs adding the same address at once: one wins, the other answers 409 and the form asks to check the rows and try again.
  • Pages cannot be removed or reordered yet; that is a separate decision.

Provenance

Human: “i agree with you UX strategy. let's do it.”

Artifacts

  • file app/api/projects/[publicId]/pages/route.ts — The add-pages route.
  • file src/lib/server/projects/add-pages.ts — The transaction, the skips, and the limits.
  • file src/components/add-pages.tsx — The Add pages card and form.
  • screenshot ../pr-screenshots/add-pages/overview-add-card.jpg — The Add pages card at the end of the overview (sample data).
  • screenshot ../pr-screenshots/add-pages/add-pages-form.jpg — The form open across the row.
  • screenshot ../pr-screenshots/add-pages/add-pages-added.jpg — After adding: the new pages' cards waiting for their screenshots.
  • screenshot ../pr-screenshots/add-pages/add-pages-form-phone.jpg — The form at phone width.
  • screenshot ../pr-screenshots/add-pages/add-pages-dark-card.jpg — The card in dark.
  • screenshot ../pr-screenshots/add-pages/add-pages-dark-form.jpg — The form in dark.
  • screenshot ../pr-screenshots/add-pages/take-new-screenshot.jpg — The renamed control under a screenshot that worked.

D130 — Sign in once a week: editor sessions last 7 days

2026-10-10 · build · accepted · Human directed

Problem

An editor session lasted 12 hours and renewed only inside its last 2 hours, so a day away from Pinata meant signing in again. With a Chrome extension that signs in too (D132), that would have meant signing in twice as often.

Decision

EDITOR_SESSION_ABSOLUTE_LIFETIME_MS is 7 days (604,800,000 ms) and EDITOR_SESSION_RENEWAL_THRESHOLD_MS is 6 days (518,400,000 ms): any session at least a day old renews on its next request, with a fresh 7-day expiry. Signing in again is needed only after 7 days without using Pinata. The extension's token follows the same two constants. POLICY_VERSION moves to 2026-10-10.1; docs/EVALS.md and docs/ARCHITECTURE.md republish the values, and the tests that hardcoded 12-hour ages now derive them from the constants.

Alternatives considered

  • A fixed 7 days from sign-in, with no renewal — Active use would still end in a sign-in prompt mid-week; the existing renewal mechanism already gives a sliding window.
  • A separate, longer lifetime for the extension only — One sign-in policy is simpler to reason about, and the owner asked for the window itself to change.

Rationale

The owner asked to change the re-auth window to 7 days. The renewal threshold keeps the existing design (renew inside a threshold, D024/D098) and sets it so the window slides with use; at most one renewal a day rewrites the cookie pair.

Consequences

  • Founder sessions reuse the same two constants (D087), so a founder who opened a share link also stays in for 7 days; rotating or revoking the link still ends those sessions at once, because they are bound to the link's version.
  • A stolen session cookie or extension token is useful for longer. The production SESSION_SECRET is shorter than the advised 32 characters (the server logs a warning); rotating it to a long random value matters more now.

Provenance

Human: “also, change the re-auth to a 7-day window rather than whatever it is now.”

Artifacts

  • file src/lib/boundaries/session.ts — The two session constants.

D131 — Screens behind a sign-in: a Chrome extension captures them, and Pinata imports captures

2026-10-10 · build · accepted · Agent proposed, human approved

Problem

Pinata's capture visits a page from a remote browser as a stranger, so anything behind a sign-in, on a staging host, or on the editor's machine came back as a sign-in page. The workaround was to save the pages as HTML, publish them on a public host, and capture those addresses. There was no way to comment on a screenshot taken any other way.

Decision

A Manifest V3 Chrome extension (extension/, loaded unpacked, no build step) captures the tab the editor is looking at. Through chrome.debugger it drives the tab the way the provider drives its browser: Desktop (1440) and Mobile (390) viewports at DPR 1, one lazy-scroll pass, animation disabled, the provider's own element pass (MANIFEST_INSPECT_SOURCE with the same limits, copied into extension/shared.generated.js and drift-checked by a test), and one full-page WebP screenshot of exactly the document that pass measured; then it restores the tab. It runs in the service worker, so closing the popup does not stop it. Its result goes one of two ways: "Send to Pinata" (D132), or "Download file", a .pinata.json capture file (format pinata-capture, version 1: the page address and title, the time, and per device the viewport, document size, element list, and base64 image). A failed send saves the file instead. Pinata imports both through POST /api/imports, one device per multipart request (a meta JSON part and an image part, D133), from the editor's page or the extension's token: the image is decoded structurally and must be exactly the document size it claims, the element list gets the provider envelope's schema and the same server-side bounding, the image is stored privately, and one transaction writes the idempotency record, a new project or page when needed, and the capture as the next ready attempt with origin upload. In the editor: an "Upload a capture" card on the overview (a capture file's address picks or makes the page), "Upload a new version" under every capture (always that page), and "Start from a capture file" on New project. A plain PNG, JPEG, or WebP uploads too: re-encoded in the browser as WebP (which drops its metadata), a high-DPR screenshot scaled to the device width, and no element list, so marks on it attach to no element. /pins?project=<publicId> opens a project, which is where the extension's "Open in Pinata" goes.

Alternatives considered

  • Authenticated capture: give the capture provider the editor's sign-in for each site — Pinata would hold session cookies or passwords for other services, handle MFA, SSO, and expiry per site, fail when a session lapses, and widen what a breach exposes; the editor's own browser is already signed in.
  • Upload saved HTML and have the provider render it — Saved pages lose fonts, styles, and images that sit behind the same sign-in; the extension captures what the editor actually sees.
  • Stitch viewport screenshots with chrome.tabs.captureVisibleTab — Sticky headers repeat in every slice and there is no device emulation; the DevTools protocol gives one true full-page screenshot at a chosen viewport.
  • A browser-side element pass written for the extension — Elements would be chosen differently from provider captures; sharing the provider's exact source keeps nearest-element pre-selection identical.

Rationale

The owner asked for a Chrome plugin that screenshots a page and pulls down the rendered DOM as one uploadable asset, image upload with comments, and feedback on whether authenticated capture would be better. The agent recommended the extension over authenticated capture, with the capture file, the upload card, origin upload, and no provider retry for uploads. The owner pulled Send to Pinata into scope and approved the rest. Chrome shows a "started debugging this browser" bar during a capture; that is the cost of the protocol, and the README says so.

Consequences

  • An uploaded capture is never retried by the provider (it would photograph the sign-in page); its next version is another upload.
  • Mobile is the page re-laid out at 390 pixels with touch and a mobile user agent, not reloaded, so a site that serves different markup to phones only on a fresh load shows its responsive layout.
  • Element pre-selection on uploads behaves exactly as on provider captures, including the box from the owner's report: a box over the toolbar now attaches to the toolbar's elements.
  • The extension's copy of the element pass must be regenerated (npm run extension:build) when the server's changes; the test suite fails until it is.

Provenance

Human: “Let's make another change to the canvas import, similar to our import for authenticated screens, where we did a runaround and inserted a public URL and support the ability to comment on an image upload, along with HTML, and package together. Simultaneously, give me a small Chrome plugin that I can use to screenshot a page and pull down the rendered DOM as a single asset that can be uploaded into pinata to perform the feedback loop. Alternatively, if you think there's a better implementation, let me know. Perhaps that's biting the bullet and actually supporting authentication to get there, but I think that's actually a larger lift with integration and other interfaces. Simply performing a long screenshot or similar with DOM extraction is probably simpler, but I'd be curious to hear your feedback.”

Agent proposed: “Recommended skipping authenticated capture and building the extension instead: the extension runs the same in-page element pass the server uses and takes a true full-page screenshot through chrome.debugger at Pinata's Desktop and Mobile sizes; it saves one .pinata.json capture file; Pinata gets an upload next to Capture that takes the file or a plain image, validates it like provider output, stores it privately, and marks it origin upload with no recapture; raw-HTML upload left out, and Send to Pinata as a later step.”

Human approved: “let's actually pull this into scope for now, since it's just me. also, change the re-auth to a 7-day window rather than whatever it is now. expand the scope to include (1) the core extension, (2) pinata auth for me (see above) for "send to pinata", (3) option not to "send to pinata" and just download the necessary pieces to upload into pinata otherwise, (4) pinata supporting this sort of import. you can go ahead and take this and run with it!”

Artifacts

  • file extension/README.md — Installing and using the extension.
  • file extension/capture.js — The capture through the DevTools protocol.
  • file src/lib/server/extension/shared-source.ts — The extension's generated copy of the capture contract.
  • file src/lib/server/imports/import.ts — Validating, storing, and filing an upload.
  • file app/api/imports/route.ts — The import route.
  • file src/components/upload-capture.tsx — The upload card and panel.
  • screenshot ../pr-screenshots/capture-extension/box-attaches-element.jpg — A box over a heading on an extension capture of a signed-in page attaches to that heading.
  • screenshot ../pr-screenshots/capture-extension/overview-uploaded.jpg — Uploaded captures on the overview, with the Upload a capture card before Add pages.
  • screenshot ../pr-screenshots/capture-extension/upload-capture-file.jpg — Uploading a capture file: the page it goes to and what is in it.
  • screenshot ../pr-screenshots/capture-extension/upload-capture-phone.jpg — The upload panel at phone width.
  • screenshot ../pr-screenshots/capture-extension/upload-capture-dark.jpg — The upload panel in dark.
  • screenshot ../pr-screenshots/capture-extension/upload-new-version.jpg — A plain image uploaded as version 2 of a Mobile capture.
  • screenshot ../pr-screenshots/capture-extension/new-project-from-file.jpg — Start from a capture file on New project.

D132 — The extension signs in to Pinata with its own token and sends captures directly

2026-10-10 · build · accepted · Human directed

Problem

Downloading a file and uploading it again is two steps for every capture. Sending straight from the extension needs it to act as the editor, but the editor's cookie is SameSite=Strict, every write checks the Origin and a CSRF proof, and an extension's requests come from its own origin.

Decision

The popup signs in with the editor password through POST /api/extension/session, which has the login route's boundary and shares its throttle buckets, and returns a bearer token, never a cookie. The token is the session's HMAC over SESSION_SECRET with its own version prefix (x1) and audience, so it never verifies as a cookie session and a cookie never verifies as it; it lasts 7 days and comes back renewed once a day old in the x-pinata-token response header (D130). It is kept in chrome.storage.local and sent in the Authorization header, which no browser attaches on its own, so those requests need no CSRF proof or Origin check. It opens only the extension's routes: GET/DELETE /api/extension/session (check or sign out), GET /api/extension/projects (live projects and their pages, to pick a target and spot a page the project already has), and POST /api/imports. The popup shows the tab's address (editable), a project (the one that already has this page, else the one this site went to last, else New project with a name), Desktop and Mobile, and Send to Pinata beside Download file; after a send it links to the project and selects it for the next send. The Pinata address defaults to https://yourpinata.dev; http://127.0.0.1:3100 works for local runs, and any other address asks Chrome for permission first.

Alternatives considered

  • Reuse the browser's Pinata cookie from the extension — It would need SameSite and Origin exceptions on the editor's mutation boundary, which exist to stop other origins writing as the editor.
  • Pair the extension from a signed-in Pinata page — It needs a page-to-extension channel (a fixed extension id or a content script on Pinata); typing the password once a week is simpler for one editor.

Rationale

The owner pulled Send to Pinata into scope with sign-in for the one editor. A separate token kind keeps the cookie boundary exactly as it was, and the shared throttle means the extension adds no new way to guess the password.

Consequences

  • Signing out of the extension forgets its token and revokes it on the instance that answers; like editor logout, that revocation is per instance, and the token otherwise ends at its expiry.
  • Under the local-only auth bypass (D052), extension sign-in accepts any password, as the page does.

Provenance

Human: “let's actually pull this into scope for now, since it's just me. also, change the re-auth to a 7-day window rather than whatever it is now. expand the scope to include (1) the core extension, (2) pinata auth for me (see above) for "send to pinata", (3) option not to "send to pinata" and just download the necessary pieces to upload into pinata otherwise, (4) pinata supporting this sort of import.”

Artifacts

  • file src/lib/server/auth/extension-token.ts — The extension token.
  • file src/lib/server/auth/extension-guard.ts — Bearer authorization, and the import route's editor-or-extension guard.
  • file app/api/extension/session/route.ts — Sign in, check, sign out.
  • screenshot ../pr-screenshots/capture-extension/extension-popup-sign-in.jpg — The popup signed out: Download works, Send needs a sign-in.
  • screenshot ../pr-screenshots/capture-extension/extension-popup-send.jpg — Signed in: project, devices, Send to Pinata.
  • screenshot ../pr-screenshots/capture-extension/extension-popup-sent.jpg — After sending, with the link to the project.

D133 — Uploads go through Pinata's own route, one device per request, with a 4 MB image cap

2026-10-10 · build · accepted · Agent decided alone

Problem

A Vercel Function accepts at most 4.5 MB of request body, and a full-page screenshot plus its element list can be larger, especially as PNG.

Decision

Each device is its own request, and an uploaded image may be at most UPLOAD_IMAGE_MAX_BYTES (4,000,000 bytes) inside a request capped at UPLOAD_REQUEST_MAX_BYTES (4,450,000 bytes), enforced while the body is read. The extension screenshots as WebP and steps the quality down (90 to 40) until the image fits; a page too tall for WebP (over 16,383 pixels) falls back to PNG and fails if that does not fit. The upload form does the same for a plain image. MAX_IMAGE_BYTES still bounds everything stored.

Alternatives considered

  • Browser-to-Blob client uploads with a token route — It splits validation from the upload (the server would download and re-check, and delete what fails), needs a completion step that does not work against localhost, and adds a third party to the extension's network surface, all for pages that fit in 4 MB as WebP.
  • One request with both devices — Two screenshots routinely exceed the 4.5 MB body limit.

Rationale

Inside the latitude the owner gave ("take this and run with it"): the server keeps its existing decode-then-store order (D044, VAL-CAPTURE-014) with no new storage pattern. Full-page WebP at DPR 1 is typically well under 1 MB (the end-to-end run measured 15 to 52 KB on Pinata's own pages).

Consequences

  • Very long, image-heavy pages may lose image quality to fit, and a page that cannot fit even at quality 40 is refused with a message rather than stored.

Artifacts

  • file src/lib/boundaries/upload.ts — The upload limits and the capture file format.

D134 — An upload's address is a label: localhost, ports, and plain http are allowed

2026-10-10 · build · accepted · Agent decided alone

Problem

Page identity was defined by the project URL rule (public https only, no ports, no IP literals, no reserved names) because the provider fetches those addresses. Uploads exist for exactly the screens that rule refuses: a local build on http://localhost:3000, a staging host on a port, an intranet name.

Decision

normalizeUploadUrl gives an upload's page identity: wherever the project rule admits the address, its result exactly (so an upload of a page the project already has lands on that page); beyond it, http, ports, reserved names, and IP literals are allowed. Credentials, other schemes, oversize input, and fragments are still refused or dropped. Nothing fetches an upload's address, and an uploaded capture is never offered a provider retry (VariantSummary.retryable is false for origin upload); a page filed this way is captured by the provider only if its address also passes the project rule and the capture admission that runs before every provider call.

Alternatives considered

  • Keep the project rule for uploads — It would refuse the local and staging screens the extension is for.

Rationale

Inside the latitude the owner gave: the address is only displayed and matched, and the provider-side guards (D043, the DNS and redirect admission) are unchanged, so a label cannot become a fetch.

Consequences

  • Founders and agent briefs may show local addresses (for example http://localhost:3000/settings) as page labels.

Artifacts

  • file src/lib/url/normalize.ts — normalizeUploadUrl beside the project rule.

D135 — Look at Codex's in-browser element comments

2026-10-10 · design · pending · Raised and deferred

Problem

The owner pointed at Codex's in-browser commenting: pick an element on the live page and type (or dictate) a comment in a bubble anchored to it. Pinata's extension captures the page and the comment happens on the canvas afterwards.

Decision

Deferred: compare later whether the extension should also take a comment on the live page, with the element picked there, and send it as a pin on the capture.

Rationale

Raised as a side note for later while the extension was being built.

Consequences

  • The extension's capture and the import route are where such a comment would plug in: a pin with an element id from the same element list.

Provenance

Human: “side note for later: look at what Codex has now!”

D136 — Keep a brand reference at the repository root, updated with every material branding decision

2026-10-11 · design · accepted · Human directed

Problem

The brand lived in pieces: tokens and their contrast notes in app/globals.css, the mark in src/lib/brand-mark.ts, the illustration style in docs/brand/homepage-illustration-prompts.json, the voice and casing rules in D078 and D127, and the direction in D102, D104, D107 and D119. Nothing gathered them, so a new surface (the extension popup, the walkthrough) had to rediscover the rules and sometimes diverged.

Decision

brand.md at the repository root gathers the name and casing rules, taglines, voice, the mark, the light and dark tokens with their contrast contracts, the Inter weight scale and type sizes, shape and spacing, motion, how marks are drawn, the illustration style, theming, the accessibility floor, the surfaces that diverge on purpose, and a checklist for changing any of it. The sources stay authoritative (the stylesheet, the mark module, the decision log, the tests that lock them); brand.md explains them in one place. Any material branding decision gets a record here and an edit to brand.md in the same commit; AGENTS.md carries that rule.

Alternatives considered

  • Put the brand reference under docs/brand/ beside the exploration board — The owner asked for it at the root, where AGENTS.md and README.md already are, so an agent or a person opening the repository sees it first.
  • Generate it from the stylesheet the way DECISIONS.md is generated from decisions.json — Half of the brand is prose (voice, casing, what the accent may color, why the dashboard looks different) that no stylesheet carries; a generator would only reproduce the token table.
  • Move the token values out of the stylesheet into brand.md and have the CSS read from it — The stylesheet, the mark module, and the walkthrough theme already share values through tests; adding a build step for a Markdown file buys nothing and risks the zero-dependency docs tooling.

Rationale

Requested by the owner. Writing it from what is already shipped, rather than from a fresh design pass, keeps it honest: every value in the file is one a test or a stylesheet already enforces, and the two places that drift (the extension popup, the walkthrough weights) are named rather than papered over.

Consequences

  • brand.md is a maintained document: a branding change without an edit to it is incomplete, the same way a decision without a regenerated DECISIONS.md is.
  • The file cites the tests that lock each value (visual-tokens, brand-mark, walkthrough-slides) so a change knows which assertions it must update.
  • The character's species is left open in copy: the code says llama, the illustration prompts said donkey; brand.md says to call it the piñata character until the owner picks.
  • The extension popup's divergences (system font, accent-filled primary button) are recorded as known rather than fixed in this change.

Provenance

Human: “create a brand.md file at root of the project based on all font, styling, color, etc, we've used thus far and want to adhere to going forward. we should update this file if we make material branding decisions.”

Artifacts

  • file brand.md — The brand reference.

D137 — Publish landing discovery metadata and public-only crawl entry points

2026-10-11 · build · accepted · Human directed

Problem

The landing had only a title and description. Search engines, social unfurls, and agents lacked an image, a canonical URL, and crawlable public entry points.

Decision

Set the production origin as the metadata base; give only the root page its canonical URL, Open Graph and Twitter cards using existing same-origin landing art. Publish robots.txt, a sitemap of the seven public pages, and a short llms.txt pointing at the landing, walkthrough, and requirements hub. Keep private workspace, founder, agent-capability, and API paths out of discovery.

Alternatives considered

  • Put canonical and social URLs on the shared root layout — Next.js inherits layout metadata on every child route, so a founder page or walkthrough would incorrectly advertise the homepage as its canonical URL.
  • Create a new externally hosted social-card image — The landing already ships a same-origin illustration, and external assets would add an unnecessary request and maintenance surface.

Rationale

The owner explicitly asked for search, social, and agent discovery of the landing. Reusing the existing copy and art and limiting crawl entries to anonymous public routes makes this a bounded implementation choice without new product claims or dependencies.

Consequences

  • The root canonical and social URL use https://yourpinata.dev; child pages do not inherit that canonical.
  • Only the seven named public pages appear in the sitemap; new public pages must be added deliberately.
  • Founder and agent-link response headers remain noindex, nofollow, noarchive, and robots.txt disallows those paths as well as /pins and /api/.

Provenance

Human: “<@U0C894SB9NH> please check the pinata root/landing page for all the relevant og and other metadata so that the landing page is search, social, and agent ready for discovery and consumption”

Artifacts

  • file app/page.tsx — Landing-only canonical and social metadata.
  • file app/robots.ts — Public crawl allowlist and protected-route exclusions.
  • file app/sitemap.ts — The public page list.
  • file public/llms.txt — Short agent discovery guide.

D138 — The agent brief leads with text: nearby elements, screenshot sizes, and crop-before-viewing

2026-10-11 · build · accepted · Agent proposed, human approved

Problem

The owner noticed that a coding agent reading the agent link (D121) cuts each full-page screenshot into pieces and pays for every piece, and asked whether the hand-off could cost fewer tokens. A full-page capture is tall (the mobile Chickpea page is about 13,000 px), and a vision model shrinks any image to roughly 1,568 px on its long edge before reading it, so the whole image is unreadable and the agent has to crop around each mark. The brief told it to "download each screenshot" without saying how big the image was or that the element text and path were often enough.

Decision

The brief now leads with text. Each mark lists the elements around it from the capture's own persisted manifest (tag, text, position, size; the nearest four that have something to read, never the mark's own elements), ranked the way the composer ranks candidates. Each screenshot link names the image's pixel size. The "What to do" section says to start from the text, open the image only when the note cannot be understood without it, and crop about 1400 × 800 px around the mark before viewing. The copy export is unchanged: the nearby lines are an optional field it does not fill.

Alternatives considered

  • Serve crops from Pinata (a crop route, or one crop stored per mark) — Removes the agent's cropping overhead but not the image tokens, and needs an image codec the repository does not have (stored captures are PNG and WebP). Deferred in D139; see docs/RECOMMENDATIONS.md R001 and R002.
  • Store viewport-height tiles at capture time — No dependency, but more storage per capture and pressure on the 120-second Browserless session. Deferred in D139; R003.
  • Leave the brief as it was — An agent read the whole image first (wasted), then cropped every mark with local tooling, including marks whose note was plain from the text.

Rationale

The avoidable cost was around the crops, not in them: the wasted whole-image read, the tooling to crop, and crops for notes the text already explained. Nearby elements cost a few dozen tokens each and replace most crops; the size line lets an agent crop straight away when it does need to look. No dependency and no database change: the manifest and the document size were already persisted per capture.

Consequences

  • The brief route reads each open mark's capture row (size and manifest) in one extra query per request.
  • A capture without a manifest or a recorded size (older rows) gets the plain link and no Nearby lines; nothing is invented.
  • docs/RECOMMENDATIONS.md is the log for suggestions not taken up yet, starting with the three above.

Provenance

Agent proposed: “Do options 1 and 2 now. They attack the dominant cost, need no dependency, and fit the existing pure-formatter tests. Treat option 3 or 4 as a later decision only if agents still crop often after the text carries neighbours and dimensions.”

Human approved: “go ahead with options 1 and 2, and set up a recommendaiton log for later.”

Artifacts

  • file src/lib/agent-brief.ts — The brief's instructions and the screenshot size line.
  • file src/lib/server/agent/nearby.ts — Nearby elements per mark, from the capture's manifest.
  • file docs/RECOMMENDATIONS.md — The recommendations log.

D139 — Serving crops or tiles of a screenshot to agents

2026-10-11 · design · pending · Raised and deferred

Problem

Beyond the text-first brief (D138), the agent could be spared cropping altogether if Pinata served a crop around each mark, stored one per mark, or stored viewport tiles at capture time. Each needs either an image codec (a new native dependency) or more capture-time work and storage.

Decision

Deferred: revisit once the text-first brief has been used on a few projects. If agents still crop on most marks, pick between a crop route, a stored crop per mark, and capture-time tiles. The options, their costs, and what would make each worth doing are written up in docs/RECOMMENDATIONS.md (R001, R002, R003).

Rationale

The owner took the two no-dependency changes and asked for the rest to be logged for later rather than decided now.

Consequences

  • docs/RECOMMENDATIONS.md exists from here on as the place for suggestions that are raised but not yet acted on.

Provenance

Human: “go ahead with options 1 and 2, and set up a recommendaiton log for later.”

Artifacts

  • file docs/RECOMMENDATIONS.md — R001 to R003: the deferred options.

D140 — The demo video opens the landing under the hero, filmed from the real product scene by scene

2026-10-15 · design · accepted · Human directed

Problem

The landing's demo (D127) was drawn in the browser from the example fixture: a toolbar of text buttons, dark comment bubbles, and a copied-link pill the product never had. As the product grew (the pin panel, the founder and agent links, capture import), the video showed less of it, and it sat third on the page, below the hero and three static illustrations. A drawn demo also has to be redrawn by hand whenever the interface changes.

Decision

The demo video now sits directly under the hero, at the full content width, with a button per chapter (Capture, Annotate, Share, Reply, Hand off). Every frame is a screen of the real product, filmed by `npm run demo:film` (scripts/demo/) from the production build on a scratch Pinata seeded with the demo data (D142): a project created and its page captured, a pin and a circle drawn and commented, the founder link created and copied, the founder replying in place, the reply arriving, and the agent link with the brief it returns. Remotion plays those screens with a cursor, typing, the circle being drawn, camera moves, and chapter titles, in the page's own theme; each screen is filmed in light and dark. The film is split into six scenes. Each scene records its screens' targets, text fields, and mark positions in remotion/demo/shots.json and lists the source files that draw them, so `--list` reports which scenes went stale and `--scenes` films only those, leaving the rest of the video as it was. Without script, and with reduced motion, the video is a still of the real workspace with a play button. This replaces the drawn demo from D127; that record's casing changes stand. The still example lower on the page keeps its capture, thread, and pin table.

Alternatives considered

  • Keep drawing the demo from the fixture — A drawing cannot look like the product, and this one had already drifted from it; every interface change would mean redrawing by hand.
  • Record one screen video (MP4) of the product — Any change means recording the whole thing again, a recording cannot follow the page between light and dark, and each update adds a multi-megabyte file to the repository. Filmed screens can be refilmed one scene at a time, and the player is already a dependency (D072).
  • Put the video in the hero, in place of the illustration — At the hero's half width the product's text is unreadable, and the hero illustration is the brand's character (D107). Under the hero the video gets the full width.

Rationale

The owner asked for the video to move up because it represents the product better than the illustrations, for it to show the product as it is now, and for a reusable reference so that videos can be updated in part as the product changes. Filming the product itself answers the second; splitting the film into scenes, each with a recorded list of what is on its screens and which files draw them, answers the third: a changed screen is refilmed alone, and the cursor follows its buttons to wherever they moved.

Consequences

  • public/demo/ holds 23 screens in each theme, about 1.2 MB a theme as WebP. The landing loads only the theme it shows, and only once the player is on the page.
  • A screen in the video shows the product as it was when its scene was filmed; after a change to that part of the interface, `npm run demo:film -- --list` names the stale scenes and `--scenes` refilms them. Filming needs a production build and port 3100.
  • test/demo-video.test.ts fails when a beat names a shot, target, text field, or mark that a refilm removed, or when a screen is missing in either theme.
  • The same change restores the phone layout of the landing's sign-in card, whose one-column rule was lost when D126 removed a neighbouring selector; e2e/landing.spec.ts now checks the card's width and that text and controls stay on screen at 390 and 320 px.

Provenance

Human: “Also, let's bring up the video higher on the page as it's more representative of the product than any of the static illustrations A video should also be updated to reflect the latest state of the product. Ideally, when you build out the video you put together a reusable reference for the iteration of the product so that we can update videos in part rather than whole as the product iterates and not necessarily every screen needs to be regenerated in the video”

Artifacts

  • screenshot screenshots/D140-landing-video.png — The video right under the hero, with its chapters (light).
  • screenshot screenshots/D140-video-frames.png — Frames from the video: capture, pin, the founder's reply, the agent's brief (dark).
  • file remotion/demo/README.md — How the video is filmed and played, and how to update one scene.
  • file scripts/demo/story.mjs — The story, scene by scene, as the product is driven through it.
  • file remotion/demo/timeline.ts — The video's beats over the filmed screens.
  • file src/components/demo-video.tsx — The player on the landing, its chapters, and the still.

D141 — A scratch Pinata can keep its screenshots on local disk

2026-10-15 · build · accepted · Agent decided alone

Problem

Every screenshot lives in private Vercel Blob, so a local run against a scratch database could create projects but never show a capture: the 2026-10-13 sandbox sessions noted the canvas showing only alt text. Filming the demo (D140) that way would have meant writing screenshots to the production store with the production token and deleting them afterwards, on every filming.

Decision

With PINATA_LOCAL_BLOB_DIR set, the server keeps screenshots in that folder through the same ScreenshotStore contract the Blob adapter has (put, head, get, delete, with the same results and error codes). The variable is honored only when TURSO_DATABASE_URL is a file: database and VERCEL is unset, and pathnames cannot leave the folder; both rules are enforced in code and tested. Unset, nothing changes. The file calls carry turbopackIgnore so the build does not trace the whole repository into the server functions that can reach the store. scripts/seed/scratch.mjs sets the variable for every scratch run.

Alternatives considered

  • Use the production Blob store and delete what the run wrote — Every filming would write to and delete from production storage, and a run that died midway would leave objects behind.
  • A second Blob store for scratch runs — Needs an account-level resource and a token on every machine or agent that films, which the sandboxed sessions do not have.
  • Write capture rows into the database directly — Couples the tooling to the schema, and skips the import path the video shows.

Rationale

Safe to decide without asking: it is local tooling inside the owner's request for a reusable way to regenerate the video. It cannot take effect in production (refused on Vercel and beside any database that is not a local file), and with the variable unset the server behaves exactly as before.

Consequences

  • A scratch run (`npm run seed`, `npm run demo:film`) needs no cloud credentials and never touches the shared database, Blob, or Browserless.
  • A database row written by a scratch run points at a file on one machine; that is why the store refuses any database that is not a local file.

Artifacts

  • file src/lib/server/providers/local-store.ts — The local store and its guards.
  • file test/server/local-store.test.ts — The contract, the guards, and path confinement.

D142 — Keep seed data in the repository and replay it into a scratch Pinata through the API

2026-10-15 · build · accepted · Human directed

Problem

Filming the demo (D140), checking a change locally, or giving another agent a realistic workspace all need a known state: a captured page, marks with notes and elements, a founder link, replies. That state existed only in the shared database, which local runs must not touch, and no script built one.

Decision

scripts/seed/ keeps one seed, chickpea.json: the Chickpea project, its pricing page (an HTML copy laid out so its elements sit where the landing's example puts them), and the four marks with their elements, notes, and the founder's replies, the same as the landing's example (a test keeps the two in step). apply.mjs replays it into a running Pinata through the product's own API in stages (empty, captured, marked, shared, replied, handed-off): the page is captured on desktop and mobile the way the Chrome extension captures one (D131) and imported, then the marks, links, and replies are created as the editor and the founder would. scratch.mjs starts a throwaway Pinata on a file database with screenshots on disk (D141) and applies a seed; `npm run seed` does both for a look around.

Alternatives considered

  • Commit a database file of the demo state — It breaks with the next migration and carries ids and timestamps; replaying through the API breaks only when the API changes, and loudly.
  • Capture the real chickpea.co with Browserless — It would depend on someone else's live site and on the production capture service, and the page could change under the video.
  • Build the state by hand in each script that needs it — Each script would hold its own copy of the data and drift from the others and from the landing's example.

Rationale

The owner proposed keeping a seed in the repository to regenerate the video from as the application changes, updating the seed as it goes. Stages let any scene of the video start from its own point without replaying the ones before it through the interface.

Consequences

  • The seed and the landing's example change together: test/seed.test.ts fails when their marks, notes, or replies differ, or when the seed's page lacks an element a mark names.
  • A new kind of state (a resolved mark, a second capture version, another page) is a new stage in apply.mjs and new data in the seed.
  • The e2e specs that look for a seeded Chickpea project still read the shared store; moving them onto this seed is in docs/NEXT.md.

Provenance

Human: “Would it be helpful for future video generation implementation to have a seed data set stored in the repository that can be used? As I iterate on the application. We can spin off updated seed data as we go”

Artifacts

  • file scripts/seed/README.md — Stages, usage, and how to change the seed.
  • file scripts/seed/chickpea.json — The seed.
  • file scripts/seed/apply.mjs — The replay, stage by stage.

D143 — Give editor project, page, device, and note durable addresses and restore archived projects

2026-10-15 · build · accepted · Agent proposed, human approved

Problem

Workspace navigation lived only in React state, so a reload or shared editor address lost its page, device, and note. Archiving hid a project without an editor control to bring it back (D109).

Decision

Keep the editor-only /pins route and its existing ?project= public-ID links. Extend its query with stable page ID, desktop or mobile device, optional capture version, and note ID. Browser history follows workspace navigation; a note resolves only against the authorized project's pins. GET /api/projects keeps active projects separate from archivedProjects for the editor, and DELETE /api/projects/[publicId]/archive restores an archive behind the same session, origin, and CSRF boundary as Archive. Archived pages, captures, pins, threads, and capability links are unchanged.

Alternatives considered

  • Use transient card positions or in-memory selection as the address — Positions change when pages are added or captures retried, and in-memory state is lost on reload.
  • Expose archived projects in the active list — It reverses the reason archiving was added: keeping finished work out of the active list.

Rationale

The owner accepted the artifact's combined recommendation. Query parameters extend the existing /pins?project= link without new route authorization rules or a migration. The editor can find archived projects in a separate section and restore them without changing the founder's access.

Consequences

  • Page and note links are editor-only; anonymous visitors are redirected by /pins before any hierarchy is read.
  • The active project list and stale-capture recovery still exclude archived projects; the editor gets a separate archived list for restoration.
  • D109's deferral is answered. Further branches based on D142 must reconcile decision IDs before merge.

Provenance

Agent proposed: “Unarchive with addresses that last (rec 8, D109)?”

Human approved: “Unarchive with addresses that last (rec 8, D109): yes.”

Artifacts

  • file src/lib/editor-url.ts — Stable editor address contract.
  • file src/components/project-workspace.tsx — Address and browser history navigation.
  • file src/lib/server/projects/archive.ts — Idempotent restoration.

Supersedes D109.

D144 — Show one overview card per page with a Desktop and Mobile preview toggle

2026-10-16 · build · accepted · Agent proposed, human approved

Problem

D077 showed a separate overview card for each Desktop and Mobile capture. Reviewing a multi-page project meant scanning duplicated page addresses and twice as many cards as pages.

Decision

Show one overview card for each submitted page in page order. A Desktop and Mobile toggle changes that card's authorized thumbnail, capture state, and stable editor link without leaving the overview. The card's feedback line sums the selected captures of both devices, including local seen adjustments. Opening the selected preview navigates to its page and device; a modified click can open the same durable address in another tab. The canvas device toggle, independent capture versions, project-wide pin stepping and table, and per-capture Markdown remain as before.

Alternatives considered

  • Keep two capture cards and only shorten their labels — Two cards still repeat the page address and make the overview twice as long.

Rationale

The agent raised page cards as an alternative to D077, and the owner approved. The existing page-level feedback helper and D143 editor address contract support a page card without changing capture storage or authorizing a new image route. D077's per-capture card layout is superseded; its remaining canvas and project-wide navigation choices continue.

Consequences

  • A page with a failed Desktop capture opens on Mobile when Mobile is usable, while both states remain visible in the card's device toggle.
  • The page count represents both selected device captures, not only the device whose thumbnail is visible.
  • Each preview's open link carries project public ID, page ID, and device; ordinary clicks use the workspace's history navigation, and native modified clicks keep the href.

Provenance

Agent proposed: “Page cards vs D077 per-device cards?”

Human approved: “One card per page (rec 6): yes. This replaces the per-device cards of D077. Desktop and Mobile become a toggle.”

Artifacts

  • file src/components/project-overview.tsx — One page card, device preview, page counts, and editor address.
  • file test/project-navigation.test.tsx — Overview and navigation behavior.

Supersedes D077.

D145 — Open new captures at fit width

2026-10-16 · build · accepted · Agent proposed, human approved

Problem

D055 starts tall captures in the Entire page view, which can shrink a page to a few percent of its natural size. The review proposed a readable initial camera without changing the remaining canvas layout.

Decision

A capture with no remembered camera starts in Fit width, with the top edge visible and its width inside the stage. Fit width never enlarges beyond 100%; narrow captures center at natural size. Entire page and Natural size remain available. Returning to a capture restores its own session camera; camera gestures still end resize-follow. Camera state stays session-local, never enters browser history, and never causes annotation mutations.

Alternatives considered

  • Keep Entire page as the initial camera — The whole tall page fits, but its text opens too small to read.
  • Also replace the stacked chrome with one slim bar — The owner left that part of the recommendation undecided.

Rationale

The agent raised Fit width as a product question and the owner approved only this camera default. The approved recommendation also caps the view at 100% so a narrow screenshot does not blur from upscaling. This reverses D055's initial mode, not its other camera controls or its local-state boundary.

Consequences

  • A fresh tall capture begins near the top at a readable width rather than showing all four corners at once; a narrow capture remains centered and sharp at 1x.
  • The Entire page button still shows all four corners; Natural size remains a 1:1 camera.
  • The first-visit and hard-reload camera tests now expect Fit width. Camera memory remains per capture.

Provenance

Agent proposed: “Fit-width as the default camera”

Human approved: “1. **Fit width as the default camera (rec 1): yes.** The rest of rec 1, the single slim bar, is still undecided.”

Artifacts

  • file src/components/capture-canvas.tsx — Initial mode and saved-camera restore.
  • file e2e/canvas.spec.ts — First-open and reload camera expectations.
  • link Owner approval of the camera-only slice. (github.com)

Supersedes D055.

D146 — Offer agent, person, and Markdown handoff from one Send sheet

2026-10-16 · design · accepted · Agent proposed, human approved

Problem

Two separate header pills hid the whole-project Markdown export in the pin table. Combining them risked suggesting a previously issued secret could be copied again, contrary to the capability contract.

Decision

Replace the two header entries with one Send button and one sheet with Coding agent, Person review, and Markdown choices. The first two use their existing independent capability endpoints, retain explicit confirmation before rotating or revoking an active link, and show a new URL only at issue time. Switching choices or closing the sheet forgets a new URL; reopening reads status but cannot recover the token. Markdown copies the existing complete project export after notes load and does not issue a capability. The agent brief and its screenshot links are unchanged.

Alternatives considered

  • Keep separate header pills and the Markdown action only in the pin table — The approved Send flow asks for one entry with three choices.
  • Store or re-fetch the live token so Send can copy the same URL later — The owner explicitly kept the digest-only, one-time token posture of D018 and D097.

Rationale

The review artifact proposed consolidation and the owner approved it only under today's token rules. Reusing existing capability operations keeps the old URL invalidation and independent agent/person semantics while changing only the editor presentation.

Consequences

  • D122's two-pill header shape is superseded; its one-time link fields and confirmation behavior remain in the shared sheet.
  • A user who leaves a link choice or closes Send must rotate an active link, after confirmation, to get a new URL; the old URL then stops working.
  • The project export is available from the header even when no capture is selected, but it waits for the project notes read.

Provenance

Agent proposed: “Rec 4 (modify, security hold): combining Agent / founder / Markdown into one sheet is a product call. Re-copying a live agent token is rejected unless Lucas reverses D018/D097.”

Human approved: “Combined Send sheet (rec 4): yes, under today's token rules. One Send button opens one sheet with three choices: agent, person, and Markdown. A link is shown only when it is made. To get it again, Send makes a fresh one and the old one stops working. Tokens stay digest-only (D018, D097), and a live link is never re-copied.”

Artifacts

Supersedes D122.

D147 — Use draft pull requests for the approved editor overhaul only

2026-10-16 · build · accepted · Agent proposed, human approved

Problem

D010 usually commits directly to main, but the approved editor overhaul has independent changes to the camera, Send, navigation, and overview. Combining them before review risks destabilizing the working demo and obscures the decision trail.

Decision

For this editor overhaul, work on separate branches and deliver draft pull requests for human review. Keep Node 24 npm run validate as the gate before committing. Do not change D010 as the default rule for other work.

Alternatives considered

  • Commit every overhaul slice directly to main — This approved exception keeps independently reviewable changes off the demo's default branch until a human merges them.

Rationale

The factory proposed a narrow exception, and the owner approved draft PRs for this overhaul. It is not a reversal of the general direct-to-main rule.

Consequences

  • The Send slice is delivered as a draft PR; no agent merges it.
  • Parallel branches may each allocate the next decision ID, so later integrations must renumber and regenerate generated docs after an earlier branch lands.

Provenance

Agent proposed: “May this overhaul use separate branches and draft PRs as an exception to the repository's direct-to-main rule?”

Human approved: “Yes, use draft PRs for this overhaul”

Artifacts

D148 — Remove the project-documentation links from the page footers

2026-10-20 · build · accepted · Human directed

Problem

The editor carried a fixed footer (D105) and the landing footer a "How it's built" column, both listing Requirements, Architecture, Milestones, Decisions and Evals. Those links date from the assignment that started the project and read as scaffolding to a product user.

Decision

Remove the editor's fixed footer and the landing footer's documentation column, along with the LandingLinks component and its styles. The /reqs pages stay, reachable from the landing top bar's "How it's built" link and their own sidebar.

Alternatives considered

  • Delete the /reqs hub entirely — The owner asked only to drop the footer links; the hub is still the project's published record.

Rationale

The owner called the footer links an artifact of where the project started.

Consequences

  • The editor regains the 2.5rem the fixed footer took at the bottom of the window.
  • The e2e check that clicked each landing footer link is gone; the /reqs sidebar test still covers every route.

Provenance

Human: “remove this from teh footer on all pinata pages. it's an artifact of where we started with this thing.”