{
  "blueprint": "link-shortener",
  "builder": "Claude, following the phase prompts in order (a fresh subagent per phase that saw only the spec and the build folder)",
  "testedSpecSha256": "025486436c0337f3f409f3ce4cdd48edf3c73acc3a0c99176bede556ebb812d6",
  "reviewDate": "2026-11-04",
  "reviewMethod": "Playwright scripts (Chromium) clicked through the build as the named seed people and opened short links from separate cookie-less visitor browser contexts (one with the Slackbot-LinkExpanding 1.0 user agent), asserting on what was on screen and on the redirect status and Location; where a check says the server must refuse something the script also made the request directly. QR codes were downloaded from the Link page and decoded with OpenCV (the SVG after rendering it in Chromium), CSV exports were downloaded and parsed, outbox emails were read on the Outbox screen after the development \"Run daily job now\" control with the development clock, and the click log and the raw database files were read to confirm click kinds and that no IP address is stored. The run below is the single final run on a fresh database after all spec gaps were fixed, resetting to the seed before each phase's checks. Earlier per-phase runs found the gaps and are not counted. Results are final post-fix checks, not first-attempt success rates or an independent human audit.",
  "automatedTests": {
    "passed": 151,
    "total": 151,
    "command": "npm test",
    "rerunDuringReview": true
  },
  "gapsFixed": 69,
  "runs": [
    {
      "startedAt": "2026-11-04T10:08:24.078Z",
      "finishedAt": "2026-11-04T10:09:07.329Z",
      "phases": [
        1,
        2,
        3,
        4
      ],
      "passed": 19,
      "total": 19,
      "items": [
        {
          "phase": 1,
          "text": "Links shows the five seed links; /spring shows 342 clicks in the last 30 days and /flyer shows 57 scans.",
          "status": "pass",
          "evidence": "Signed in as Maya Lopez, Links lists 5 rows (flyer, q4-podcast, spring, spring-li, webinar-may); /spring row shows 342 clicks (30 days), /flyer row shows 57 scans."
        },
        {
          "phase": 1,
          "text": "Building a link from https://northwind.example/demo?ref=partner#form with linkedin, paid-social, spring-sale-2026 and content \"Carousel A\" previews exactly the second URL in section 6's examples.",
          "status": "pass",
          "evidence": "New link live preview shows exactly https://northwind.example/demo?ref=partner&utm_source=linkedin&utm_medium=paid-social&utm_campaign=spring-sale-2026&utm_content=carousel-a#form"
        },
        {
          "phase": 1,
          "text": "The source \"fb\", the slug \"spring\", the slug \"admin\" and the destination javascript:alert(1) are each refused with the messages in section 6 or R1.",
          "status": "pass",
          "evidence": "Create link refused each: {\"source\":\"fb\"} -> \"fb isn't an allowed source. Allowed: facebook, google, linkedin, newsletter, partner, podcast, print.\"; {\"slug\":\"spring\"} -> \"go.northwind.example/spring is taken. Try spring-2.\"; {\"slug\":\"admin\"} -> \"admin is a reserved word. Choose another slug.\"; {\"base_url\":\"javascript:alert(1)\"} -> \"Links must start with http:// or https://.\"; Links still has 5 rows."
        },
        {
          "phase": 1,
          "text": "Changing the slug of /spring is refused because it has clicks; changing the slug of a new link with no clicks works.",
          "status": "pass",
          "evidence": "Edit /spring: slug field readonly=true; a direct POST changing it to spring-new returned 422 (\"example/spring active View link Fix the following: This slug is permanent because the link has counted clicks or scans (it may already be printed).\") and Links still shows /spring. New link /partner-page (no clicks) renamed to /partners and Links shows /partners."
        },
        {
          "phase": 1,
          "text": "Signed in as Chris, he can edit /flyer but not /spring, and a direct request to edit /spring is refused.",
          "status": "pass",
          "evidence": "As Chris Doyle, Links shows Edit on /flyer only (not /spring); he saved /flyer's title as \"Q4 launch flyer (v2)\". Direct GET /admin/links/lnk_spring/edit returned 403, direct POST returned 403 (\"Not allowed · Northwind links Development Sign in as Choose a user... Chris Doyl\"), and /spring's title is still \"Spring sale pricing (newsletter)\"."
        },
        {
          "phase": 2,
          "text": "Opening /spring returns a 302 to the full pricing URL with its UTM parameters, and /spring's count goes from 342 to 343.",
          "status": "pass",
          "evidence": "A visitor browser opened http://localhost:3100/spring: 302 with Location https://northwind.example/pricing?utm_source=newsletter&utm_medium=email&utm_campaign=spring-sale-2026, and the browser went on to load that URL; Maya's Links row for /spring went from 342 to 343 clicks."
        },
        {
          "phase": 2,
          "text": "Opening it again within 30 seconds still redirects, but the count stays at 343; a request with the user agent \"Slackbot-LinkExpanding 1.0\" is recorded as a preview and not counted.",
          "status": "pass",
          "evidence": "Same visitor reopened /spring 1 s later: 302 to the pricing URL, Links still 343. Slackbot-LinkExpanding 1.0 got 302; Links still 343. Latest click rows for /spring: preview/not counted, click/not counted, click/counted."
        },
        {
          "phase": 2,
          "text": "/flyer's QR code downloads as SVG and PNG and encodes the short link with ?qr=1; opening that address redirects without qr=1 and takes the scans from 57 to 58.",
          "status": "pass",
          "evidence": "Link page downloads ls-flyer-qr.svg and ls-flyer-qr.png (1024x1024); OpenCV decodes both to https://go.northwind.example/flyer?qr=1. Opening /flyer?qr=1 gave 302 to https://northwind.example/demo?utm_source=print&utm_medium=qr&utm_campaign=q4-launch (no qr=1) and /flyer scans went 57 -> 58."
        },
        {
          "phase": 2,
          "text": "/webinar-may redirects to https://northwind.example and is recorded as expired; /nothing-here returns a 404 page with a link to the main site; / redirects to the main site.",
          "status": "pass",
          "evidence": "/webinar-may: 302 to https://northwind.example, newest click row kind=expired, not counted. /nothing-here: 404 page \"This link doesn't exist. Check the address, or go to the main site. Go to northw\" linking to https://northwind.example. /: 302 to https://northwind.example."
        },
        {
          "phase": 2,
          "text": "Changing /spring-li's destination records the old and new URLs in its history, and the next click goes to the new destination.",
          "status": "pass",
          "evidence": "Maya changed /spring-li's base URL to /pricing/teams. Its change history now shows: \"Change history When Who Field Old value New value Nov 4, 2026, 9:00 AM Maya Lopez Base URL https://northwind.example/pricing https://northwind.example/pricing/teams Nov 4, 2026, 9:00 AM Maya Lopez Destination https://northwind.example/pricing?utm_source=linkedin&utm_medium=paid-social&utm_campaign=spring-sale-2026&utm_content=carousel-a https://northwind.example/pricing/teams?utm_source=linkedin&u\". The next click got 302 to https://northwind.example/pricing/teams?utm_source=linkedin&utm_medium=paid-social&utm_campaign=spring-sale-2026&utm_content=carousel-a."
        },
        {
          "phase": 2,
          "text": "No IP address is stored anywhere in the database.",
          "status": "pass",
          "evidence": "After visits from 127.0.0.1 with X-Forwarded-For 203.0.113.77, scanned 13 tables (5583 cells) and the raw database files: no IP-address-shaped value. Click columns: id, link_id, at, kind, counted, country, referrer_domain, visitor_hash."
        },
        {
          "phase": 3,
          "text": "After a reset, campaign spring-sale-2026 shows 470 clicks: newsletter 342 and linkedin 128, or email 342 and paid-social 128.",
          "status": "pass",
          "evidence": "Campaigns, spring-sale-2026 card: 470 counted (470 clicks, 0 scans); by source {\"newsletter\":342,\"linkedin\":128}; by medium {\"email\":342,\"paid-social\":128}."
        },
        {
          "phase": 3,
          "text": "/spring's page shows the countries US 210, GB 61, CA 40 and DE 31, and 25 previews left out of the count.",
          "status": "pass",
          "evidence": "/spring Link page (/admin/links/lnk_spring), Top countries: US 210, GB 61, CA 40, DE 31; \"25 previews left out of the count (link previews in Slack, LinkedIn, WhatsApp and similar apps).\"."
        },
        {
          "phase": 3,
          "text": "Filtering Links by campaign q4-launch shows /flyer and /q4-podcast only.",
          "status": "pass",
          "evidence": "Links filtered by campaign q4-launch (/admin/links?q=&campaign=cmp_q4_launch&owner=&source=&medium=&status=) lists /flyer and /q4-podcast only."
        },
        {
          "phase": 3,
          "text": "The links export lists all five links with their UTM values, owner and counted clicks and scans; the clicks export for the last 30 days has one row per link per day with clicks.",
          "status": "pass",
          "evidence": "links-2026-11-04.csv: 5 rows, header short link|title|destination|source|medium|campaign|term|content|owner|created|status|counted clicks|counted scans; e.g. https://go.northwind.example/flyer | Q4 launch flyer | https://northwind.example/demo?utm_source=print&utm_medium=qr&utm_campaign=q4-launch | print | qr | q4-launch |  |  | Chris Doyle | 2026-09-30 10:00 | active | 3 | 57. clicks-2026-10-06-to-2026-11-04.csv (2026-10-06 to 2026-11-04): 106 rows, one per link per day (2026-10-07 to 2026-11-04), header date|slug|counted clicks|counted scans|previews; per-link sums flyer 3 clicks/57 scans/0 previews over 29 days; spring 342 clicks/0 scans/25 previews over 29 days; spring-li 128 clicks/0 scans/0 previews over 29 days; q4-podcast 19 clicks/0 scans/0 previews over 19 days."
        },
        {
          "phase": 4,
          "text": "After a reset, running the daily job emails Chris once that /q4-podcast expires in 3 days (\"expires on Oct 18\" in the examples); running it again sends nothing more.",
          "status": "pass",
          "evidence": "Seed day 2026-11-04 09:00 America/New_York; Run daily job now -> Outbox has 1 email to chris@northwind.example: \"go.northwind.example/q4-podcast expires on Nov 7\" (logged); outbox total 1. Second run -> still 1 emails, 1 reminder."
        },
        {
          "phase": 4,
          "text": "Moving the clock to the next Monday at 08:00 or later (team time) and running the job puts a digest for Maya and Chris in the outbox with each of their links' counted clicks and scans for the previous week, and none for Sana.",
          "status": "pass",
          "evidence": "Clock set to 2026-11-09 08:00 America/New_York (next Monday); Run daily job now -> 2 digests in Outbox: Maya \"Your links last week: 2 links, 40 clicks\", Chris \"Your links last week: 2 links, 2 clicks, 5 scans\"; none for Sana. Each body lists the link counts from the click log for 2026-11-02 to 2026-11-08: /flyer (Chris) 0 clicks, 5 scans; /q4-podcast (Chris) 2 clicks, 0 scans; /spring (Maya) 29 clicks, 0 scans; /spring-li (Maya) 11 clicks, 0 scans."
        },
        {
          "phase": 4,
          "text": "Changing /flyer's destination to another domain asks for confirmation because it was scanned in the last 30 days.",
          "status": "pass",
          "evidence": "As Chris, saving /flyer with base URL https://events.example.org/q4-demo showed \"This link is in a QR code that was scanned 49 times in the last 30 days.\" and left the destination at https://northwind.example/demo?utm_source=print&utm_medium=qr&utm_campaign=q4-launch; pressing \"Yes, change the destination\" saved https://events.example.org/q4-demo?utm_source=print&utm_medium=qr&utm_campaign=q4-launch."
        },
        {
          "phase": 4,
          "text": "Archiving /flyer makes it return the 404 page; links with counted clicks have no Delete action, and a direct delete request is refused.",
          "status": "pass",
          "evidence": "Chris archived /flyer; a visitor opening /flyer and /flyer?qr=1 got 404 \"This link doesn't exist. Check the addre\". As Maya (admin), the Link pages of the five seed links and the Links list show no Delete action. Direct POST /admin/links/lnk_spring/delete returned 409 (\"This link has counted clicks or scans, so it can be archived but never deleted.\") and DELETE returned 409; /spring is still in the database (active)."
        }
      ]
    }
  ],
  "notTested": [
    "Production sign-in: company-domain one-time sign-in links were built, but every check used the development \"sign in as\" switcher.",
    "Real email delivery: expiry reminders, digests and the welcome email were written to the outbox and logged, not sent; no email provider was configured.",
    "The daily job on a real scheduler: it ran from \"Run daily job now\" with the development clock; /api/cron/daily was only checked with curl (401 without or with a wrong secret, 200 with the right one).",
    "Hosting on a real short domain: the checks ran on localhost, so https on go.northwind.example, the host's geolocation header (countries came only from the seed) and a trusted proxy's X-Forwarded-For were not exercised.",
    "Redirect speed under load and the deferred click write under many simultaneous visitors were not measured.",
    "Real phone cameras: the QR codes were decoded by OpenCV from the PNG and a Chromium rendering of the SVG, not scanned from paper or a screen.",
    "Spec fixes that no acceptance check covers (method 405s, CSV formula guarding, restore after archiving, deactivated owners' reminders) are covered only by the build's own tests or not at all."
  ]
}