pinata

Decisions

Source: docs/decisions/decisions.json · Revision: 5cb1e99cae08dbd2dcee5488dec0dc61389ebadf

100 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.

D001Name 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; not a problem for an interview artifact, would matter 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

D002Public 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 laterNot 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 for this assignment.

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

D003Default 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` unilaterallyCosmetic 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.

D004Treat the decision trail as a shipped deliverable

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

Problem

The assignment is graded partly on "ability to explain your process and decisions." Reconstructing that narrative at the end of a four-hour 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 buildHindsight flattens the trail. Dead ends get quietly dropped and every choice looks inevitable.
  • Rely on the git log and the Factory session transcriptBoth 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, which is precisely what an agent-driven-development interview is probing.

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

D005One 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 MarkdownMarkdown 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 directlyBrowsers 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 generatorDependency 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

D006Build 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 turnThe 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.

D007One 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 riskThe 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 JestNode 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 testsThe documentation apparatus is already the largest thing in the repo and is itself a graded 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 warningThat 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

D008Test 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 proseUnenforced conventions decay, and this one decays in the direction of flattering the agent.
  • Only test the pure render functionsThat 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

D009Rename 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 landsThe 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.

D010Commit 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 chunkRejected 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

D011Commit 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 graded deliverable (D002), so progress that exists only locally is invisible to a reviewer 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 milestonesBatching hides the build narrative the assignment asks us to surface, and leaves work sitting locally where it can be lost.
  • Commit locally and push at milestonesThe 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 for this assignment. 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

D012Define 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 directlyExplicitly rejected by the human: "We're not making an IDE." Directional suggestions, not pedantic instructions.
  • Runtime AI to generate or transform feedbackRejected 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

D013Chickpea 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 testingA 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

D014Next.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

D015MIT 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 keyLicense cost against an explicit keep-costs-low goal from the human.
  • MIT Excalidraw embedSketch-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

D016Browserless 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 ScreenshotOneSimple 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/ChromiumThe 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

D017Private 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 TursoOffered 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

D018Editor 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 protectionBasic 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-recipientThe 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

D019Standardize 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 scaffoldWould 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

D020Product-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 onlyCannot 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 validateForbidden 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

D021Re-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 conventionA 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 lintingBoth 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

D022Serve /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 setD021 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 docsCreates 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

D023Publish 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 featureVAL-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 codeTwo 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

D024Verify 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 checksLength 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 tokenThe 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 databaseLogin 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

D025Persist 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 TursoThe 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 codeVAL-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 themCascade 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

D026Throttle 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-ipThe 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-windowOnly 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_SECRETWith 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.

D027Env-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 secretsIt 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 commandA 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 absentA 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

D028Projects 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 pagesContradicts 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 publicIt 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 laterIt 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

D029A 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 readRepair 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 keyTwo 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 runsIt 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

D030The 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 attemptAn 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 jobIt needs a scheduler the Vercel deployment does not run, and a missed sweep leaves an attempt permanently unretryable.
  • Overwrite the attempt row on retryIt 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

D031Retry 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 levelIt 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 keyTwo deliberate recaptures of the same target are legitimate; collapsing them removes the ability to recapture at all.
  • Let the key be target-freeA 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

D032Capture 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 aloneIt cannot see a rebind, and the browser resolves the name again from a different network.
  • Trust the provider private-network blocklist aloneIt 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 redirectsA 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

D033DNS 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 publiclyThe browser may prefer the family whose answer we could not read, which is the rebinding case restated.
  • Retry ambiguous answers until one resolvesIt 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 errorThat 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

D034Capture 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 objectsLossy encoding makes two captures of an identical page differ, which is exactly what the stabilization assertion measures.
  • Accept whatever the provider returnsA 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

D035Dispatch 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 endpointIt 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 reconciliationStaleness 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

D036Browserless 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 documentedURLs end up in proxy logs, error reports, and stack traces; a header does not.
  • Stop and treat the provider as blockedThe 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

D037Controlled 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 VercelDeployment 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 filesIt requires pushing, and this worker does not push.
  • Run a tunnel to the local serverNo 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.

D038The 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 arrivesThe 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 capThe 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 sortingDocument 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

D039Track 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 rowThe 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 rowA 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 foreverAn 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

D040Publish 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 filesDeclined 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 accessThe 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.

D041Capture 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 thereIt 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 hostsThree 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 typesServing 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.

D042Browserless 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 instanceIt 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 tableA 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 progressThe 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

D043Remote-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 pageThe 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 onlyThe 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 timeoutsA 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

D044Private 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 URLA 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 requestsThe 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 bytesThe 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

D045Editor 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 testAn 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-sideThe 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 pageVAL-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

D046Markdown 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 packageD021/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

D047Darken 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 usesTwo 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 landmarksThe 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

D048Make 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 focusableThe 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 rolearia-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 dependencyRound 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

D049Drive 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 commitsA 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 pollsA 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 readWhen 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

D050Trim 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 scopeThe 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 featuresRejected 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

D051Materially 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 planThe 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 recordThe 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.

D052Add 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 codeThat 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 convenienceThe 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

D053Correct 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 itThat 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 targetThe 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

D054Decide 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 nowThat 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 recordThe 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.

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

2026-09-10 · build · accepted · 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 stageExactly 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 captureD015 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.

D056Pin 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 handlerThe 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 changeWhen 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 zoomAt 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 placementD051 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.

D057Capture-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 greenThe 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 workerThe 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 conflictA 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.

D058The 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 tourDismissable 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 interactionsThe 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.

D059Pin 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 validationTwo 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 frameWrite 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 laterAn 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.

D060Navigate 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 badgesThat 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 captureScratch 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 runsThere 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.

D061Pin 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 renderedA 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 preconditionsA 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 rowsHard 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 conflictThe 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.

D062Anchor 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 toleranceThat 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 graded on.
  • Compensate by adding the threshold window back in our adapterThe 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.

D063Fix 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 exclusionOrdering 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 versionThe 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.

D064Candidate 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 treeA 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 specsTimeouts 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 flagThe 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.

D065Run-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 orderingCannot 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 runPlaywright 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.

D066The 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 routeThe 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 databaseThe 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 imageZero 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.

D067Anonymous 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 · accepted · 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 parametersUser-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 sessionStorageThe 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 loginAnonymous 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.

D068Deploy 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 itThe 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 userThe 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).

D069Split 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 existThe 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 /pinsThat 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 pageThe 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.

D070Collapse 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 regionMore code and more ways to get the announcement wrong, for behavior <details> already provides correctly and without script.
  • Default every project collapsedThe canvas is showing something; collapsing the control that produced it hides the reader's own context.
  • Default every project expanded, with collapse availableThat 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.

D071List 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-allThe user asked for the whole set in one paste; per-row copying is the manual work the table exists to replace.
  • Export JSONThe 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 detailThe 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.

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

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

Problem

The assignment is graded on explaining the product and the process, 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 the interview.

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 deckThe 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 appA 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 featureThe 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.

D073Host 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.jsonA 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 pageStudio needs a checkout and a dev server; the assignment is demonstrated 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.

D074Drop 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 hintThe 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 choiceAn 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 dropFocus 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.

D075Close 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 notificationsThere 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 entryA 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 resolveThe 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.

D076Drive 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 designVercel 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 pollingThe 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.

D077A 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 · accepted · 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 onlyExport is the last step; the missing thing is orientation while reviewing.
  • All captures on one canvasExplicitly 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

D078Speak 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 labelsHover-only content is already excluded by the quality attributes under test, and the labels were noise, not underexplained.
  • Hide internals for the founder onlyThe 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

D079Rectangle 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 firstThe human asked for the box. Circles and arrows reuse the same resize and geometry path later.
  • A separate numbering sequence for boxesOne sequence per capture keeps 'note 4' unambiguous in the thread, the table, and the export.
  • Draw by clicking two cornersPress-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.

D080Project 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 badgesA 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 capturesKeeps 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.

D081Restate 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 graded 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 onThe 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 lineIt 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-endedThe 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.

D082Circle 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 heightThe 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 heightThe 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.

D083Arrow 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 midpointThe 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 arrowsFreehand 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 nodesEdges 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.

D084Build 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 after the assignment. 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 buildThe 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 workIt 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 checkpointThe 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.

D085Founder 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 rowsA 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 claimOne 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.

D086No 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 bumpForces 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.

D087Founder 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 readDuplicates delivery and listing logic and doubles the byte-exact asset proof surface.
  • Admit founders on manifest and context routes tooThe 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.

D088Thread 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 digestTwo sources of truth for one row; the entry itself already carries every field the digest would cover.
  • Add a payload_digest column to thread_entriesA 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.

D089The 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 responseChanges 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.

D090Founder 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 pageRequires 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.

D091Disclose 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 brief requires Factory, and docs/ASSIGNMENT.md 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 time accounting: each Mission section reports agent time, but nothing stated the human's wall-clock time against the four-hour brief, and most of the agent time ran unattended while the owner teed up a prompt and walked away. Leaving either unsaid would make the process narrative inaccurate on the one criterion the decision log exists to serve.

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/ASSIGNMENT.md's "built with Factory" row names the exception and points here; docs/SESSION-LOG.md opens with a time-and-tooling ledger that separates the human's wall-clock time (under the four-hour brief, per the owner) from the agent time each session section reports.

Alternatives considered

  • Say nothing, since the MVP itself was Factory-builtThe grading criterion is honesty about process, 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 sessionThe 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 spentAgent time ran mostly unattended and overlapped with the owner's absence, so a single sum would overstate the human effort the brief bounds and understate the autonomy that is the point of the assignment. The ledger keeps the two measures apart.

Rationale

The human directed the disclosure and the time framing, with the verbatim quotes below. The decision costs nothing and makes the repository's own history and its narrative agree.

Consequences

  • docs/ASSIGNMENT.md: the "Built with Factory" row names the Claude Code exception and points here; the timebox row points at the ledger.
  • 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." — and later, on this pass: "note that we deviated from the homework a bit and used Claude Code on mobile so I could code on the plane!" — and on time: "using wall-clock time, we are under the 4 hour limit - a lot of the activity was ne teeing up a prompt and then walking away to let Droid Missions do its think mostly autonomously."

Artifacts

  • file docs/ASSIGNMENT.md The amended "built with Factory" and timebox rows.
  • file docs/SESSION-LOG.md The time-and-tooling ledger at the top of the log.

D092Flag 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 interview is graded on explaining 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 dataIt 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 /reqs/assignment as a second navigation group, as the cross-review proposedThe 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.

D093Cross-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 grades its own homework. 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 afterwardsThree 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 harnessThe 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 commitsIts 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.

D094Defer 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 rankingIt 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 permanentlyOne 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

D095Recover 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 retryableIt would weaken a deliberate policy verdict and let a retry probe the address policy repeatedly.
  • Reuse navigation-timeout or dns-failed for an unreadable probeBoth messages would misdescribe a TLS error or a reset connection to the editor.
  • A queue service for the chainA new external dependency, already rejected in D076.
  • Stop the chain when the budget runs out, without a hand-offLeaves 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 sweepThe 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:This is no longer an exercise for Factory but I'm taking it and running with it so don't Factor that in as any sort of impediment to forward progress here

Supersedes D054.

D096Canvas 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 themSmaller handles on every mark would still capture pans and wobbles.
  • Publish the 48-pixel threshold as a boundary constantIt 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 pixelsChanges the stored precision D062 fixed.
  • Retry automatically on a 409Would overwrite a genuine write from another session.
  • Keep click-disarmsThe 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:This is no longer an exercise for Factory but I'm taking it and running with it so don't Factor that in as any sort of impediment to forward progress here

D097Both 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 websocketsNew infrastructure for a product with one editor and a handful of founders.
  • Keep the capture poll runningIt stops by design once nothing is capturing.
  • Loosen the server normalizerIt is the security boundary for what gets captured.
  • Complete or upgrade to http://Would capture an address nobody typed.
  • window.confirm for Rotate and RevokeBlocking, unstyled, and hard to test.
  • Let the editor copy the current link againNeeds 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:This is no longer an exercise for Factory but I'm taking it and running with it so don't Factor that in as any sort of impediment to forward progress here

D098Sign-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_ENVLocal live sessions deliberately run the production build with the flag.
  • Keep only the global bucketIt lets anyone lock the editor out.
  • Per-client buckets onlyDoes not bound guessing spread across many addresses, D026's original concern.
  • Refuse a short SESSION_SECRETCould lock the owner out of production if the existing secret is short.
  • Fix each renewal site separatelyFourteen 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:This is no longer an exercise for Factory but I'm taking it and running with it so don't Factor that in as any sort of impediment to forward progress here

Supersedes D026.

D099Put 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, docs/ASSIGNMENT.md still said every commit came through Factory although D091 said that row had been corrected, 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. docs/ASSIGNMENT.md names the Claude Code and Cursor exceptions and this post-assignment pass, and the tooling ledger cites the right records and adds the Cursor work and this pass.

Alternatives considered

  • Run migrations in the Vercel buildA build that migrates the shared production database on every preview deployment is worse than a manual step in the right order.
  • Turn deployment protection offWould expose every preview deployment, and their per-commit URLs, as well as production.
  • Leave the Cursor commits to the git historyThe interview is graded partly on explaining how AI tools were used, and a reviewer 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.
  • Pinata is no longer a Factory exercise; work after 2026-09-23 is the owner's own project and 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:This is no longer an exercise for Factory but I'm taking it and running with it so don't Factor that in as any sort of impediment to forward progress here

D100Serve 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 nowThe 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 anywayThe migration table already listed every migration in the repository; there was nothing to apply.
  • Rewrite D099 in placeThe 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