pinata

Source: docs/EVALS.md · Revision: 5cb1e99cae08dbd2dcee5488dec0dc61389ebadf

Pinata evals

This is the human-readable catalog of how Pinata is evaluated, rendered live at /reqs/evals. The formal, executable definition of done is the mission validation contract (validation-contract.md, assertion IDs VAL-*); this catalog explains the same intent in plain language so anyone can add scenarios. When the two disagree, the contract wins.

How evaluation works

  • One gate. npm run validate runs lint and integrity, typecheck, unit and component tests, the deterministic docs check, the production build, and Chromium end-to-end tests — the same command locally and in CI.
  • Real integrations. Milestone validation exercises the real Browserless, Turso, private Blob, and Vercel surfaces. Mocks support focused unit tests; they are never the definition of done.
  • Live checkpoints. After each milestone's automated validation passes, the owner drives a headed shared-browser session while the agent inspects the same DOM, console, network, and persisted state.
  • Escapes become assertions. Any issue the owner reports is written down as a given / when / expected / observed contract assertion before a fix is implemented.

Chickpea scenarios

The primary real-world target is Chickpea (chickpea.co) plus its explicit /pricing, /about, and /privacy URLs, captured on desktop and mobile. Representative scenarios:

  1. Full-page captures include lazy-loaded content near the bottom of the page, on both viewports.
  2. Deep zoom on a dense pricing-table cell stays readable, and a pin dropped on that cell persists to the exact natural pixel after reload.
  3. Choosing the right nearby element from a dense table ranks the cell ahead of its row and table wrappers.
  4. The closed mobile menu's hidden descendants never appear as selectable metadata, while a visible header element does.
  5. Desktop and mobile annotations of the same page stay fully independent.
  6. The explicit URL array preserves its submitted order in the page tree.
  7. A failed viewport capture does not discard its successful sibling, and retry replaces only the failed attempt.

Dogfood scenarios

Pinata captures and annotates its own requirements hub. The dogfood URL array, in order:

  1. /reqs
  2. /reqs/architecture
  3. /reqs/milestones
  4. /reqs/decisions
  5. /reqs/evals

Representative scenarios:

  1. Navigate the hub from the landing page without typing a URL; every route refreshes cleanly; an unknown /reqs/* address lands on a bounded 404 with a working link home.
  2. Decision cards match docs/decisions/decisions.json exactly, including provenance quotes and supersession links — there is no second decision dataset.
  3. Capture the five routes above as a Pinata project and annotate a requirement from inside Pinata itself.
  4. Hostile markup or unsafe links in a document render inert; external links identify their destination and withhold the referrer.
  5. The hub stays usable at phone width, at 320 CSS pixels with 200% zoom, and with reduced motion preferred, with no horizontal scrolling of the page.

Landing page scenarios

How a first-time visitor meets Pinata (VAL-LANDING-001, VAL-LANDING-002, VAL-LANDING-003, D066/D067):

  1. An anonymous visitor opening / sees the pinata mark — the same inline SVG mark as the favicon — directly above the URL capture entry (a required root URL, an optional add-more-URLs control, and one visible primary action), plus a brief value proposition. The page renders with no console errors, no /api/* traffic, and no external image requests.
  2. Below the hero, a fully static example shows a marked-up capture: a screenshot region with two numbered pins, a two-entry comment thread, and a visible DOM metadata panel. It renders from bundled fixture data with no database or API dependency and depicts saved comments only — no reply control.
  3. An anonymous visitor who submits the capture entry is routed to the sign-in prompt (focus lands on the password field) without any project write; after signing in, the editor lands back on / with the entered URLs retained in the always-active form, and creating the project issues exactly one POST /api/projects answered 201, with the project listed on the same page.

Editor entry scenarios

How Lucas gets from the password prompt to a project. Representative scenarios (VAL-AUTH-008, VAL-AUTH-009):

  1. While the project list loads, the region reports itself busy and says so in text; loading, empty, populated, and failure never render at once.
  2. With zero projects, the named list says it is empty and offers exactly one create action; submitting once shows the project exactly once, already in the workspace, with no address-bar navigation.
  3. When the list read fails, the failure is announced, sign-out stays available, any half-typed create form keeps its safe input, and one press of Try again issues exactly one read; a press while that read is in flight does nothing.
  4. Reload and Back/Forward after a create never resubmit the form and never create a second project.
  5. Login, list, and create controls all have accessible names, associated errors, visible focus, and no keyboard trap; a wrong password announces a generic error and clears the password field.
  6. End-to-end runs delete their own rows in teardown, not in a trailing test, so an aborted run cannot leak run-scoped projects, pages, captures, or idempotency keys into the real database.

Role and thread scenarios

  1. A founder link opens a read/reply-only view; editing controls are absent visually and to assistive technology.
  2. Founder replies and editor follow-ups interleave chronologically and cannot be edited or deleted by anyone, enforced below the application by database triggers.
  3. Rotating a link invalidates the old link and every old session; revoking ends access without deleting project history.
  4. A stale reply composed before logout, rotation, or revocation is rejected and adds nothing.

Milestone 1 live checkpoint (2026-09-09/10)

The first live checkpoint: the owner drove their own browser against the local production build on 127.0.0.1:3100 (commit 71c8ee2, run id checkpoint-m1-2026-09-09T211026Z, server running with the D052 local auth-bypass flag) with the seeded Chickpea project — root plus /pricing, /about, /privacy, Desktop and Mobile, nine ready captures and one failed Mobile-root attempt. The session spanned roughly 2026-09-09T21:30Z to 2026-09-10T02:40Z with intermittent engagement across three mid-session fix cycles; the owner did not state active time. Per the escapes-become-assertions rule, each finding below was written down before its fix was implemented. Overall outcome: conditional acceptance — capture and organization validated; the product is not yet usable because pins and pin comments do not exist (the very next milestone, D051). Verbatim closing statement:

okay, i thought we had something usable. this doesn't let us create the most fundamental thing - creating a pin with an annotion. definitely move onto that next.

M1-LIVE-1 — the stage must show the actual screenshot

  • Given a project with ready captures in the editor workspace
  • When the owner selects a capture and interacts with the stage
  • Expected the selected capture's real screenshot renders in the stage
  • Observed the stage was a text placeholder and clicking its controls did nothing. Verbatim: "okay. so, a thing is captured, but I click on the buttons and nothing happens. please review / explain."
  • Outcome rejected as shipped; fixed mid-session (editor-capture-image-stage, commit b21385a), now formalized as VAL-CAPTURE-015.

M1-LIVE-2 — the entire capture is in view on first open

  • Given a ready tall capture (the Chickpea pages run ~9k–13k px)
  • When the owner opens it in the stage
  • Expected the whole capture is visible at once, without scrolling
  • Observed the capture rendered at natural size and ran far off screen. Verbatim: "the content is being successfully fetched/screenshotted! but it should be presented such that the entire page is in view. see screenshot."
  • Outcome fixed mid-session (capture-stage-fit-view-and-hints, commit 0a27d77): fit-to-view is the default with a labeled natural-size toggle; the contract's VAL-CANVAS-002 now carries this as a user-directed requirement.

M1-LIVE-3 — the pin-drop mechanism must be discoverable from the page

  • Given a ready capture on screen
  • When the owner looks for how to drop a note on it
  • Expected the page itself explains the interaction, and the mechanism exists
  • Observed no pin mechanism exists yet and nothing on the page said so. Verbatim: "what hte heck is the interactin mechanism to drop a note? I can't figure it out? add instrucitons on teh page itself to make it self-documented"
  • Outcome open requirement, captured as VAL-CANVAS-009 (self-documenting canvas interactions) for the canvas/pins milestone; the interim stage gained a plain-language hint in the meantime.

M1-LIVE-4 — wide captures must pan horizontally at natural size

  • Given a wide Desktop capture viewed at natural size
  • When the owner tries to scroll right to see the rest of the page
  • Expected the stage pans on both axes; the document never overflows horizontally
  • Observed the image overflowed the page and nothing scrolled horizontally. Verbatim: "this still goes off the page when expanded. i can't scroll right either - the page scroll seems fixed horizontally - so i can't view the entire page that's been captured." In the same exchange the "canvas update" hint jargon was impenetrable: "what does \"pins and comments arrive with the canvas update. Use natural size to scroll into fine detail.\" mean? i literally can't figure out how to create a pin and comment on it. tell me what to do."
  • Outcome fixed mid-session (capture-stage-natural-scroll-and-plain-copy, commit 71c8ee2): the scroll region is bounded on both axes (minmax(0, 1fr) tracks, min-width: 0 on the detail and scroll containers) and the hint now says plainly that pinning and commenting are not available in this build yet.

M1-LIVE-5 — owner-driven retry recovers a capture end to end

  • Given a failed capture attempt sitting next to ready siblings
  • When the owner presses the variant's Retry control themselves
  • Expected one fresh attempt is dispatched through the real provider and appears as a new ready version
  • Observed exactly that: the owner triggered "Retry Desktop capture" unaided and the retry produced a ready Version 2 end to end through the real Browserless provider, with no worker intervention.
  • Outcome accepted behavior — positive evidence for the retry and dispatch-driver design (VAL-CAPTURE-008, D049).

Published boundaries

Every runtime boundary is exported exactly once from src/lib/boundaries/ (policy version 2026-09-23.3, constant POLICY_VERSION). Unit tests import the same constants and compare them against this page, docs/ARCHITECTURE.md, and the deployed /reqs routes; any drift between code, docs, and deployed content fails the gate, and duplicating one of these literals anywhere else in the application is a defect.

ConstantValuePolicy
POLICY_VERSION2026-09-23.3Dated catalog version; bumps on any boundary change.

Editor session

ConstantValuePolicy
EDITOR_SESSION_ABSOLUTE_LIFETIME_MS43,200,000 ms (12 hours)A session is never valid past its absolute expiry.
EDITOR_SESSION_RENEWAL_THRESHOLD_MS7,200,000 ms (2 hours)Renewal is allowed only when the remaining lifetime is inside this threshold; a renewal sets a fresh absolute expiry.
AUTH_REQUEST_MAX_BYTES1,024 bytesLogin/logout request bodies larger than this are rejected before parsing.
EDITOR_PASSWORD_MAX_CHARS256The password field accepts at most this many characters.

URL and input limits

ConstantValuePolicy
MAX_SUBMITTED_URL_ROWS32Rows beyond the limit are rejected before any normalization or persistence.
MAX_UNIQUE_PAGE_URLS16Unique normalized page URLs retained per project.
MAX_URL_BYTES2,048 bytesPer submitted row, measured as UTF-8.
BLANK_URL_ROW_POLICYignoreBlank optional array rows are ignored; the root URL is always required.
PROJECT_REQUEST_MAX_BYTES69,632 bytesProject-create bodies larger than this are rejected before parsing.
PROJECT_TITLE_MAX_CHARS120Longest accepted project title; blank defaults to the root host.
IDEMPOTENCY_KEY_MIN_CHARS8Shortest accepted mutation idempotency key.
IDEMPOTENCY_KEY_MAX_CHARS128Longest accepted mutation idempotency key.

URL normalization fixtures

Normalization trims surrounding whitespace, rejects rows over MAX_URL_BYTES, then parses once with WHATWG URL, lowercases scheme and host, converts IDNA hosts to punycode, strips a single trailing host dot, removes the default :443 port and the fragment, resolves dot segments, treats backslashes as path separators, drops an empty query, and turns an empty path into /. Path case, percent-encoding (so %7E and ~, and encoded separators such as %2F, stay distinct), and query order/duplicates are preserved. Canonicalization happens before any address check, so every IP literal — including numeric spellings and private, link-local, carrier-grade, multicast, and metadata addresses — is rejected as ip-literal, while hosts that cannot exist publicly (single-label names and the reserved localhost, local, internal, intranet, home.arpa, invalid, test, and onion suffixes) are rejected as not-public. Public cross-origin HTTPS rows are allowed. These exact fixtures pin the behavior:

FixtureInputExpected
scheme-and-host-are-lowercasedHTTPS://EXAMPLE.COM/Pricinghttps://example.com/Pricing
empty-path-becomes-roothttps://example.comhttps://example.com/
default-port-443-removedhttps://example.com:443/pricinghttps://example.com/pricing
non-443-port-rejectedhttps://example.com:8443/reject: port
fragment-removedhttps://example.com/pricing#teamhttps://example.com/pricing
query-order-and-duplicates-preservedhttps://example.com/pricing?ref=a&ref=bhttps://example.com/pricing?ref=a&ref=b
empty-query-droppedhttps://example.com/pricing?https://example.com/pricing
encoded-tilde-preservedhttps://example.com/%7Emehttps://example.com/%7Eme
encoded-slash-preservedhttps://example.com/a%2fbhttps://example.com/a%2fb
idna-host-becomes-punycodehttps://bücher.example/https://xn--bcher-kva.example/
trailing-dot-strippedhttps://example.com./pricinghttps://example.com/pricing
backslash-treated-as-slashhttps://example.com\pricinghttps://example.com/pricing
dot-segments-resolvedhttps://example.com/a/../b/./chttps://example.com/b/c
trailing-slash-is-distincthttps://example.com/pricing/https://example.com/pricing/
credentials-rejectedhttps://user:pass@example.com/reject: credentials
http-scheme-rejectedhttp://example.com/reject: scheme
ip-literal-rejectedhttps://127.0.0.1/reject: ip-literal
numeric-ip-spelling-rejectedhttps://2130706433/reject: ip-literal
ipv6-loopback-rejectedhttps://[::1]/reject: ip-literal
relative-input-rejected/pricingreject: relative
blank-input-rejected(blank)reject: blank
surrounding-whitespace-trimmed https://example.com/pricing https://example.com/pricing
cross-origin-https-allowedhttps://docs.example.org/guidehttps://docs.example.org/guide
unsupported-scheme-rejectedjavascript:alert(1)reject: scheme
localhost-rejectedhttps://localhost/reject: not-public
single-label-host-rejectedhttps://intranet/reject: not-public
private-range-literal-rejectedhttps://10.0.0.5/reject: ip-literal
metadata-address-rejectedhttps://169.254.169.254/latest/meta-data/reject: ip-literal
root-and-slash-are-one-pagehttps://example.com or https://example.com/https://example.com/ (same page)

/pricing and /pricing/ normalize to different pages; https://example.com and https://example.com/ normalize to the same page. Rejections happen before any capture attempt or project row exists.

Capture dimensions, time, and bytes

ConstantValuePolicy
DESKTOP_VIEWPORT1440 × 900 CSS px, DPR 1Desktop captures render at natural CSS-pixel dimensions.
MOBILE_VIEWPORT390 × 844 CSS px, DPR 1Mobile captures add a mobile user agent and touch emulation.
MAX_DOCUMENT_HEIGHT_PX16,384 pxTaller documents fail as document-too-tall.
MAX_DOCUMENT_PIXELS25,000,000 pxLarger documents fail as too-many-pixels.
MAX_IMAGE_BYTES8,388,608 bytes (8 MiB)Larger screenshots fail as image-bytes-exceeded.
ALLOWED_IMAGE_CONTENT_TYPESimage/png, image/webpOnly these decode into a stored screenshot; anything else fails as invalid-image. Capture asks for the first entry, PNG, so pixels are lossless and one stabilized layout is reproducible.
MAX_PROVIDER_RESPONSE_BYTES16,777,216 bytes (16 MiB)Larger provider responses fail as provider-bytes-exceeded.
NAVIGATION_TIMEOUT_MS30,000 msPer-navigation budget; exceeding it fails as navigation-timeout.
NETWORK_IDLE_TIMEOUT_MS5,000 msPost-navigation network-idle budget.
LAZY_SCROLL_STEP_PX800 pxLazy-loading scroll increment.
LAZY_SCROLL_MAX_STEPS24Enough steps to reach the bottom of a maximum-height page.
LAZY_SCROLL_STEP_DELAY_MS250 msSettle delay per scroll step.
TOTAL_CAPTURE_TIMEOUT_MS90,000 msWhole-capture deadline, inside the provider's 120-second session cap; exceeding it fails as total-timeout.
CAPTURE_INVOCATION_MAX_DURATION_MS300,000 ms (5 minutes)Lifetime of one capture-running function invocation; every capture route's maxDuration equals it.
CAPTURE_CONTINUATION_MARGIN_MS60,000 msReserve beyond one capture deadline an invocation must have left to start another; otherwise the chain is handed to a fresh invocation.
MAX_REDIRECT_HOPS5Every hop is revalidated under the same public-HTTPS rules.
DNS_TIMEOUT_MS3,000 msPer-query DNS budget; a query that outlives it fails the capture as dns-failed.
MAX_CNAME_HOPS8CNAME hops followed before the chain is refused as unresolvable.
REDIRECT_PROBE_TIMEOUT_MS5,000 msPer-hop budget for the server-side redirect preflight.

Capture destination admission

A capture target is admitted only when it canonicalizes to an absolute public HTTPS URL and resolves publicly. Canonicalization runs first, so every IP spelling is rejected as a literal before any range check. DNS then follows a bounded CNAME chain and queries both A and AAAA: the host is admitted only if every answer parses and every answer is public. A timeout, a server failure, an ambiguous answer from either family, an empty result, a loop, or a single non-public answer rejects the host. Rejections never disclose a resolved address.

Every top-level redirect hop is revalidated under the identical scheme, credential, port, canonical-host, DNS-answer, and address-range rules, up to MAX_REDIRECT_HOPS; a chain that stays public completes and the capture stores both the requested and the final public URL.

Browserless runs in a different network, so application-side DNS proves nothing about what the remote browser resolves. The capture function therefore installs a request guard that revalidates every top-level navigation and refuses subresource requests to credentialed hosts, reserved hosts, non-HTTP(S)/WebSocket schemes, and all IP-literal hosts, while ordinary public subresources continue to load. The guard never disables web security, TLS validation, sandboxing, or the provider's private-network blocklist.

These prefixes are never a capture destination:

RangeWhy
0.0.0.0/8this network
10.0.0.0/8private
100.64.0.0/10carrier-grade NAT
127.0.0.0/8loopback
169.254.0.0/16link-local and cloud metadata
172.16.0.0/12private
192.0.0.0/24IETF protocol assignments
192.0.2.0/24documentation (TEST-NET-1)
192.88.99.0/246to4 relay anycast
192.168.0.0/16private
198.18.0.0/15benchmarking
198.51.100.0/24documentation (TEST-NET-2)
203.0.113.0/24documentation (TEST-NET-3)
224.0.0.0/4multicast
240.0.0.0/4reserved and broadcast
::/96unspecified and IPv4-compatible
::ffff:0:0/96IPv4-mapped
64:ff9b::/96NAT64
64:ff9b:1::/48local-use NAT64
100::/64discard-only
2001::/32Teredo
2001:2::/48benchmarking
2001:db8::/32documentation
2002::/166to4
fc00::/7unique-local
fe80::/10link-local
ff00::/8multicast

Capture attempts, concurrency, and staleness

ConstantValuePolicy
MAX_CAPTURE_ATTEMPTS_PER_PROJECT64Initial attempts plus retries; a maximum-size project starts with 32.
MAX_ACTIVE_CAPTURES2Matches the Browserless free-tier concurrency limit; dispatch beyond it fails as quota-exceeded.
MAX_AUTOMATIC_CAPTURE_RETRIES1Automatic retries the server creates in a row for one page device after a retryable failure or a stale attempt, counted since the last attempt a person asked for; the next failure waits for a manual retry (D076).
STALE_CAPTURE_AGE_MS300,000 ms (5 minutes)A capturing attempt older than this computes to stale and becomes retryable. The durable concurrency lease for the attempt expires at the same age, so an abandoned claim frees its Browserless slot exactly when the attempt becomes retryable.
CAPTURE_CLEANUP_WINDOW_MS3,600,000 ms (1 hour)Orphan-cleanup retry window; a known orphan past it is still deleted on sight, never kept.
CAPTURE_REQUEST_MAX_BYTES1,024 bytesHard cap on a capture mutation body, enforced before parsing.
CAPTURE_POLL_INITIAL_INTERVAL_MS2,000 msFirst delay between read-only capture-progress polls.
CAPTURE_POLL_MAX_INTERVAL_MS10,000 msBackoff ceiling for capture-progress polls (doubling from the initial interval).
CAPTURE_POLL_DEADLINE_MS600,000 ms (10 minutes)A poller stands down after this even if work looks unfinished; longer than the stale age so abandonment is always observed as computed stale first.

Concurrency admission is durable: capture_leases holds exactly MAX_ACTIVE_CAPTURES slot rows in Turso, and claiming a slot is one atomic conditional upsert, so the limit holds across browsers and application instances. When every slot is held, dispatch fails as quota-exceeded (HTTP 429) without consuming the attempt — it stays pending and any later authorized client can resume it. A lease releases when its attempt reaches a terminal state and is reclaimed in place once it expires; a late release can never free a slot another attempt already reclaimed. Polling issues hierarchy GETs only — it can never create, duplicate, or mutate an attempt — and stops on any terminal or computed-stale state.

DOM manifest schema

ConstantValuePolicy
MANIFEST_SCHEMA_VERSION1Persisted per capture as dom_manifest_version.
MAX_MANIFEST_ELEMENTS500Overflow keeps the capture ready with a manifest-truncated warning.
MAX_MANIFEST_BYTES262,144 bytes (256 KiB)Exact persisted UTF-8 JSON size; never exceeded.
MANIFEST_TEXT_MAX_CHARS120Short visible text per element.
MANIFEST_ACCESSIBLE_NAME_MAX_CHARS120Accessible name per element.
MANIFEST_MAX_CLASSES8Bounded class hints per element.
MANIFEST_HINT_MAX_CHARS64Any single safe hint value (id, class, testId).
MANIFEST_MAX_COMBINING_MARKS8Consecutive combining marks kept on one base character.
MANIFEST_RECT_MAX_PX100,000 pxAbsolute bound on a rectangle coordinate or extent; anything larger is dropped.
MANIFEST_PATH_MAX_DEPTH12Structural-path segments per element.
MANIFEST_RECT_DECIMALS2Decimal places on document-space rectangle coordinates.
MANIFEST_ELEMENT_KEYSid, kind, tag, role, text, accessibleName, hints, path, rectThe exact element key set; no other keys may appear.
MANIFEST_HINT_KEYSid, classes, alt, title, testIdThe exact hint key set.
MANIFEST_ELEMENT_KINDSlandmark, heading, link, control, image, table-cell, details, summary, textThe bounded element-kind enum.

Supported motion and tolerances

Capture freezes or pauses what it safely can from the same stabilized layout state as the screenshot, and warns on the rest:

CasePolicy
CSS animationsfrozen
CSS transitionsfrozen
Text caretshidden
Web Animations APIpaused
Video elementspaused
Animated images (GIF/APNG/WebP)first-frame
Canvas/JS-driven animationunsupported-warn
Sticky/parallax layersas-rendered

Exactly what each policy does, so the result is reproducible rather than merely still:

  • frozen — capture-only CSS sets animation: none and transition: none, so the element renders its base style instead of whichever frame the clock happened to be on. Freezing in place would be still but not reproducible.
  • hiddencaret-color: transparent, which removes the blinking caret without moving or resizing the field.
  • paused — every document.getAnimations() animation is paused and rewound to time zero; every <video> is paused. A rewound Web Animation is reproducible; a paused video frame is not, so video regions are excluded from reproducibility comparisons.
  • first-frame — an animated image is covered by a canvas holding the frame createImageBitmap decodes, which is the format's default (first) frame. When the bytes cannot be re-read (opaque cross-origin response), the capture warns instead of claiming a freeze.
  • unsupported-warn — a visible <canvas> may be repainted by script at any moment; capture cannot freeze it and says so in a warning.
  • as-rendered — sticky and parallax layers are captured where they sit after the scroll returns to the top, and the capture warns that their position is scroll-dependent.

Every warning code is derived from this matrix (motion-unsupported:canvas-js, motion-as-rendered:sticky-parallax, motion-first-frame:animated-image), so no capture can invent a motion warning outside the published cases.

ConstantValuePolicy
MOTION_ANCHOR_TOLERANCE_CSS_PX1 pxAnchor geometry must survive stabilization within one CSS pixel.
MOTION_MASKED_MAX_DIFF_RATIO0.001At most 0.1% of pixels in a masked deterministic region may differ.

Capture outcome catalog

Every capture outcome maps to exactly one row: its persisted status, whether retry is offered, the HTTP status of the API response ( marks a computed or persisted state rather than a response), whether it consumes a persisted attempt, and whether it surfaces as a warning on a ready capture. Public messages and remediation text live in the catalog and are bounded by MAX_PUBLIC_MESSAGE_BYTES | 256 bytes.

CodeStatusRetryableHTTPConsumes attemptWarning
invalid-urlfailedno422nono
dns-failedfailedyes502yesno
unsafe-redirectfailedno422yesno
target-unreachablefailedyes502yesno
browserless-authfailedno502yesno
browserless-providerfailedyes502yesno
navigation-timeoutfailedyes504yesno
total-timeoutfailedyes504yesno
document-too-tallfailedno422yesno
too-many-pixelsfailedno422yesno
provider-bytes-exceededfailedyes502yesno
image-bytes-exceededfailedno422yesno
invalid-imagefailedyes502yesno
quota-exceededfailedyes429nono
blob-failurefailedyes502yesno
finalization-failurefailedyes502yesno
stale-leasefailedyesyesno
cleanup-pendingreadynoyesyes
manifest-truncatedreadynoyesyes

Private asset delivery

Screenshots are stored in a private Vercel Blob store; the only way bytes reach a browser is GET/HEAD /api/captures/<captureId>/asset, which verifies the actor's authority on every request — including requests that end in 304, 206, or an empty HEAD — and never redirects to or names the provider. A capture id resolves through its project: a ready capture of a live project with a complete storage record is served; every other case (anonymous, expired, tampered, or ended authority; nonexistent, pending, capturing, failed, deleted-project, or integrity-failed captures) receives the one bounded generic denial with no bytes. Served bytes are revalidated against the persisted content type, byte length, and SHA-256 before anything is returned, and the strong ETag is exactly that SHA-256. A single bytes=<start>-<end?> range is honored (206 with Content-Range); suffix, multi-range, reversed, and non-numeric ranges are rejected without bytes, and an unsatisfiable range answers 416 with the published length. If-None-Match (weak forms and * included) and If-Modified-Since are answered from the persisted hash without a provider read.

ConstantValuePolicy
ASSET_CACHE_CONTROLprivate, no-store, max-age=0Every asset response; no browser or intermediary may retain private bytes after authority ends.
ASSET_VARYCookieAsset authorization rides on the authority-carrying Cookie header, so any cache key must include it.
ASSET_RANGE_UNITbytesThe only range unit served; exactly one range with an explicit start.

Geometry minimums

ConstantValuePolicy
MIN_SHAPE_SIZE_PX8 pxMinimum rectangle/circle extent in screenshot-natural pixels; circles stay square.
MIN_ARROW_LENGTH_PX16 pxMinimum arrow start-to-end distance in screenshot-natural pixels.

Login and reply quotas

Both quotas are enforced in the durable store, so they hold across tabs and application instances, and both recover after exactly the published window.

ConstantValuePolicy
LOGIN_MAX_FAILURES5Failed editor logins allowed per client per window before generic throttling.
LOGIN_GLOBAL_MAX_FAILURES100Failed editor logins allowed per window across all clients before generic throttling.
LOGIN_WINDOW_MS900,000 ms (15 minutes)Login throttle window and recovery interval.
REPLY_MAX_PER_WINDOW30Founder replies accepted per window.
REPLY_WINDOW_MS3,600,000 ms (1 hour)Reply rate-limit window and recovery interval.

Login enforcement semantics (D098, superseding the single shared bucket of D026): every attempt is counted in two rate_limit_buckets rows before the password is checked — a per-client row keyed by the SHA-256 digest of the editor-login scope and the client address (Vercel's x-real-ip, else the first x-forwarded-for entry, else a fixed unknown), limited to LOGIN_MAX_FAILURES, and a global row keyed by the scope alone, limited to LOGIN_GLOBAL_MAX_FAILURES. Keys are digests; a password or secret is never part of one. Reserving before verifying means parallel requests cannot multiply the guesses a window allows, and the per-client row means one guessing address cannot lock the editor out, while the global row still bounds guessing spread across many addresses. The window is fixed at the first attempt in it; throttled attempts receive the same generic 429 with a bounded Retry-After header, never reach the password check, and never extend the window. A correct attempt succeeds immediately once window_started_at + LOGIN_WINDOW_MS has passed; a successful login clears its client row and returns its one slot to the global row.

Feedback, annotation, and interaction limits

ConstantValuePolicy
FEEDBACK_BODY_MAX_CHARS2,000Shared maximum length of an original comment or a thread reply.
MAX_ANNOTATIONS_PER_CAPTURE200Persisted annotations per capture, across all kinds.
NEARBY_CANDIDATES_MAX8Nearby DOM candidates offered when placing a mark.
ANNOTATION_REQUEST_MAX_BYTES16,384 bytesByte cap for annotation create and move requests.
CLIENT_REQUEST_TIMEOUT_MS15,000 msEvery client request reaches a terminal state within this budget.
MIN_HIT_TARGET_CSS_PX24 pxShared minimum pointer/touch hit target (WCAG 2.2 AA, 2.5.8).
PLACEMENT_SLOP_SCREEN_PX6 pxPointer travel at or below this between press and release is a click (drop a pin, select a pin); more is a drag (pan the camera, move a pin).
ARROW_HIT_TOLERANCE_CSS_PX12 pxHalf the width of the band around an arrow shaft that takes the pointer; an arrow has no area, so selecting one is a distance-to-segment test, and twice this is the shared minimum hit target.

Performance protocol and budgets

ConstantValuePolicy
PERFORMANCE_PROTOCOL3 measured runs, Chromium, desktop viewport, 16 GB RAM / 10 logical cores, maximum-dimension fixture annotated to the per-capture annotation maximum, evenly by kind and heightThe fixed measurement protocol; every run must pass every budget.
PERF_IMAGE_TO_USABLE_P95_MS3,000 msp95 from image response to a usable canvas.
PERF_PAN_ZOOM_CYCLES60Pan/zoom cycles per run.
PERF_SELECTION_CYCLES100Selection cycles per run.
PERF_INPUT_TO_PAINT_P95_MS100 msp95 input-to-paint latency during the cycles.
PERF_LONGEST_TASK_MS200 msLongest allowed main-thread task.
PERF_RETAINED_HEAP_MAX_BYTES268,435,456 bytes (256 MiB)Post-GC retained heap ceiling.
PERF_DETACHED_NODES_MAX25Detached DOM nodes tolerated after GC.
PERF_CAMERA_REQUEST_BUDGET0Camera cycles issue zero annotation writes and no increasing reads.

Quality attributes under test

  • Warm, playful, focused design; the whole product demos live in under a minute.
  • No horizontal page overflow, clipped controls, or hover-only content at any supported width.
  • Keyboard-operable controls with visible focus; reduced-motion preference respected.
  • Bounded, actionable error states that never leave silent partial project state.
  • No application secret or founder capability in any public response, asset, log, or committed file.