record:source_weights is a judgement call. A field recovered from a locked record is reported OBSERVED/LIKELY because the original mechanism was never stored. LIKELY is my choice of the honest middle. If it should be NEEDS_CONFIRMATION instead, that is a one-line change — but the real fix is a source_mechanisms column, which is a migration.
The visual direction conflict in §11. It needs deciding, not guessing.
The Instagram route decision is now sharper and still Ivan's. The two Meta routes are not a fallback pair (§7), so choosing the simpler one first costs the whole setup twice. Against: an official route reads professional accounts only, and in Phuket a real share of businesses post from personal accounts, which a scraper reads and Meta never will.
The unverified weekly cap on business_discovery — ask Meta during App Review. It may decide viability.
Nothing has run against a database this session. blueprintFromLockedRow is tested as a pure function; the Supabase round trip is NOT_RUN, and so is npm run test:db.
The blueprint's unsaved path is NOT_RUN in a browser. No browser here. The pure function has 112 assertions and the view rendered server-side, but the sessionStorage read itself has not been exercised.
Thai typography still needs a Thai reader. Thai text renders in the blueprint and the extractor declines to guess Thai phrases (no segmenter) — but whether it *looks right* is not something I can check, and CLAUDE.md says Ivan cannot either.
I fast-forwarded onto another agent's branch (§0). If that chain was not meant to be built on, this branch inherits it and the supervisor should know before merging.
Starting SHA: 372223e *(The Linux run is a fact now, not an open item)* Branch: agent/bulgar-dna-web/20260825T205705Z-01bd Finishing SHA: 5c4393e Date: 25 August 2026 · Linux, Node v22.16.0, container with no credentials
Outbound internet verified before anything else: example.com 200, registry.npmjs.org 200. Live DNS confirmed for the SSRF cases.
372223e was thirteen commits behind origin/agent/bulgar-dna-web/20260825T192354Z-23d0, a linear chain by an earlier agent ending at 135de46 — 4,591 insertions covering priorities 1–4 and 7, including the SSRF fix I was told to build on.
I fast-forwarded onto it rather than working from 372223e. Building website capture from the older tip would have meant re-implementing or reverting that agent's work, which the brief explicitly forbids. The merge was a clean fast-forward; nothing of theirs was changed except where a later finding contradicted it, and each of those is called out below.
So this report has two halves, and I have tried hard not to blur them:
Inherited (caa4c35..135de46, not mine) | SSRF hostname + connect-time address guards · evidence/mechanism registry · conflict detection · intake_sources rows · Instagram access states · accessibility on the walkthrough · stale-draft guards |
Mine (1e8f86b..5c4393e) | everything in §2 onward |
Test counts: 904 → 1121 assertions across the session; the inherited work accounts for 443 → 904, mine for 904 → 1121.
Walked the code, not the docs. NOT_PRESENT below means the code does not do the thing, whatever a document says.
| Thing | Evidence |
| Website fetch + extraction | Fetched example.com and a 575KB gzipped Wikipedia page live; both extracted |
| SSRF guards (two layers) | 24 refusal cases run live — table in §4 |
| JSON-LD / OpenGraph / nav / social extraction | Live PADI scan reads jsonld:Organization.name, jsonld:sameAs, meta:description, nav:structure |
| Mechanism-based provenance | evidence.ts registry; every finding carries one |
| Conflict detection | resolveField + conflict.ts; exercised in tests and by my own change |
intake_sources row per attempt | Built in record.ts, threaded through the walkthrough |
| Six screens, build clean | next build green; /, /onboarding, /blueprint all 200 |
| Compositing (M5 core) | 26 assertions; not touched by me |
| Thing | State |
| Supabase persistence | NOT_RUN — no credentials in this container, by design (decisions/0004). Code paths degrade to honest empty states, verified: /blueprint renders "nothing is being stored on this copy" |
npm run test:db (21 checks) | NOT_RUN — needs a database |
Client sessionStorage paths | NOT_RUN — no browser in this container (no Playwright, no Chromium) |
| Claim | Reality |
docs/13-stack.md names Playwright | Not used. Plain node:https. Deliberate, reasoned in fetch-site.ts |
| Instagram provider | NOT_PRESENT. No account, no token. capabilities.instagram is off |
| Prose voice fields from a model (M4) | NOT_PRESENT |
docs/06-intake.md §5.2 "Graph API will not cover accounts we do not manage" | Wrong. Corrected by the previous agent; re-verified and extended by me — §7 |
Four, each with the measurement that found it. All fixed. Details in §2–§5.
request.setTimeout is an inactivity timer. A server sending one byte every three seconds resets it forever and never trips it.
Measured against exactly that server: the read was still running at 25 seconds having received 20 bytes, with nothing going to stop it. One request pinning a connection is a slow leak; a handful is the server. Reachable by anyone who can type into the onboarding address box.
readCapped now takes the deadline for the whole chain, already reduced by every redirect hop, so a site cannot buy time by bouncing us around first. Running out of time is a failure, never a short page — returning what arrived would hand the extractor a partial document indistinguishable from a complete one, and the review screen would report "no phone number" about a page we stopped reading.
I initially wrote that decompress's empty .on('error', () => {}) silently truncated bodies, and committed that claim. It does not. Measured against a gzip stream cut off before its trailer: the async iterator still rejected with Z_BUF_ERROR. The handler only stops the error being *unhandled*.
No page was ever silently truncated. What remains is a naming fix: a decode failure fell into the catch-all and was reported as network, whose message reads *"We could not reach your site."* We did reach it. It answered and we could not read what it said — a different fact, pointing at a different cause, and the one that belongs in intake_sources.error_note. It is undecodable now. The correction is written into the commit message and the code comment.
localhost. is a fully-qualified name resolving identically (verified: ::1 and 127.0.0.1) and walked past every rule in url.ts — the suffix test does not match .local., the IPv4 pattern does not match 127.0.0.1.. The connect-time check caught it, so this was a hole and not a breach. But a layer that only works when the layer below works is not a layer, and the one below costs a DNS round trip to say no. Now refused in 0ms.https://admin:hunter2@intranet.example.com/ asks our server to authenticate to somebody else's machine. Refused, not stripped — stripping quietly fetches a different page than the one asked for and says nothing, putting a fact on the review screen sourced from a page nobody knows we read.The address rules answer *which machine*; this answers *which service on it*, and the second still has teeth after the first is satisfied — a database on a public host whose only real defence is that strangers cannot route to it, and Redis reads the lines of an HTTP request it understands and ignores the rest.
Deliberately not an allow-list of 80/443: real small businesses publish on 8080 and 8443, and refusing those breaks honest sites to close nothing.
facts.extractName reads jsonld:Organization.name first — a field whose only purpose is to state the name. extract.extractBusinessName starts at og:site_name and has never heard of JSON-LD. Both ran on every scan; only the weaker became a candidate.
So on a site with proper structured data, the strongest fact intake can find was extracted, shown to the owner as a finding, and dropped before anything could use it. Measured against live PADI:
| Before | After | |
| Value | PADI | Professional Association of Diving Instructors |
| Mechanism | html:title.split | jsonld:Organization.name |
| Standing | INFERENCE | OBSERVED |
| Certainty | NEEDS_CONFIRMATION | CONFIRMED |
With two candidates from one source, PADI's field went CONFLICTING against itself — "PADI" vs the full name is a material disagreement by any reading. It seeded nothing, claimed no provenance, and asked the owner to settle a dispute between a page and itself.
A conflict is a disagreement between SOURCES, not between two readings of one page. This could not arise while each source offered one candidate per field. A weak inference losing to a strong observation is the weak method being weak, and the registry already says which reading is worth more. Raising it buries the real conflicts — which is how a conflict system gets switched off; conflict.ts says so in its own header. Site-vs-bio disagreements are untouched, asserted both ways.
body.tsNot tidiness. The address guard makes `fetchSite` deliberately unable to reach any server this repository can start — every test server binds to loopback, and refusing loopback is the guard's whole job. Pointing the fetcher at a trickling server is impossible *by construction*. Split out, the rules are driven by real streams from a real local server over plain http.get: nothing mocked.
Every claim answers "where did DNA learn this?" with a machine identifier, not a sentence:
Business name Andaman Divers
From your own pages
the business name published in your site's data
https://www.andamandivers.com/ [jsonld:Organization.name]Three separate things, and they are kept separate on purpose:
mechanism — branched on, re-checkable (jsonld:Organization.name)detail — the sentence a person readssourceUrl — the final URL after redirects, which is what re-checking needsThe four standings never blur: OBSERVED / USER_SUPPLIED / INFERENCE / CONFLICTING, plus a fifth — UNKNOWN — that exists only in the blueprint, because Standing describes how a claim was *obtained* and has no word for a field nobody has claimed anything about.
The load-bearing invariant, enforced structurally and hardest-tested: a line carries its provenance or it carries no value. A value the assembler cannot attribute drops to UNKNOWN. *"Here is a fact, we cannot tell you where it came from"* is worse than *"we do not know this"* — the first gets believed, the second gets fixed.
setName(outcome.businessName)); the review screen printed origin="you told us" unconditionally. Identical to the fault 0919945 fixed for languages, still standing on the field this product singles out as the most visible thing intake can get wrong, in the largest type on the screen. Fixed with nameOrigin, which travels through sessionStorage and is written to source_weights at the lock.sourceWeightsForLock builds the map by walking the QUESTIONS, and no question asks the name — so a locked record could not say the website chose its own name, and *"why did this change?"* is the question that column exists for.Resolved, then stopped at the plan boundary — so anything reading a plan rather than a merge had prose where it needed an identifier. A disputed field is deliberately absent from both: while two sources disagree there is no mechanism by which we know the value.The mechanism is discarded at the lock. brand_dna.source_weights records *which* source decided a field and never *how* it read it. A name that was CONFIRMED from jsonld:Organization.name during the session comes back knowing only that the website decided it.
I did not invent a certainty to fill that. The recovered claim gets a distinct mechanism — record:source_weights, OBSERVED / LIKELY — whose why reads *"the locked record names the source but not how it read it; the mechanism is not stored."* LIKELY rather than CONFIRMED because the original certainty is exactly what was lost; a title-split read comes back through the same door.
The fix is a column, not a better guess — source_mechanisms alongside source_weights, written at the lock from the same plan. That is a migration, and migrations were out of scope. This needs a human decision.
Run live against the real fetchSite at the finishing SHA. invalid_url = refused by the hostname layer before a socket; blocked_address = refused at connect time after the resolver answered.
| Case | Result | Time |
http://127.0.0.1:9000/ | refused invalid_url | 1ms |
http://10.0.0.5/ | refused invalid_url | 0ms |
http://192.168.1.1/ | refused invalid_url | 0ms |
http://172.16.0.1/ | refused invalid_url | 0ms |
http://169.254.169.254/latest/meta-data/ | refused invalid_url | 0ms |
http://100.64.0.1/ (CGNAT) | refused invalid_url | 0ms |
http://0.0.0.0/ | refused invalid_url | 0ms |
https://[::1]/ | refused invalid_url | 0ms |
https://[::ffff:127.0.0.1]/ | refused invalid_url | 0ms |
http://router.local/ | refused invalid_url | 0ms |
http://metadata.internal/ | refused invalid_url | 0ms |
| `localhost.` (trailing dot) | refused invalid_url | 0ms |
| `http://169.254.169.254./` | refused invalid_url | 0ms |
| `https://admin:hunter2@example.com/` | refused invalid_url | 0ms |
| `http://example.com:6379/` (redis) | refused invalid_url | 0ms |
| `http://example.com:22/` (ssh) | refused invalid_url | 0ms |
javascript:alert(1) | refused invalid_url | 0ms |
file:///etc/passwd | refused invalid_url | 0ms |
gopher://example.com/ | refused invalid_url | 0ms |
| `http://127.0.0.1.nip.io/` → 127.0.0.1 | refused `blocked_address` | 18ms |
| `http://169.254.169.254.nip.io/` → metadata | refused `blocked_address` | 3ms |
| `http://10.0.0.5.nip.io/` → 10.0.0.5 | refused `blocked_address` | 4ms |
`http://localtest.me/` → ::1 | refused `blocked_address` | 80ms |
https://example.com/ — must succeed | FETCHED, 559 bytes | 356ms |
Bold = closed by me; the rest were inherited and re-verified.
Also verified live: example.com and a 575KB gzipped Wikipedia page both read and extracted, so none of the hardening broke real fetching.
Redirects (inherited, re-verified in the suite): every hop re-validated through both layers; a 302 → http://169.254.169.254/ is refused; five-hop limit; each abandoned redirect body destroyed to release the socket.
New adversarial cases in body.test.mts, all against real servers, nothing mocked:
| Server behaviour | Expected | Result |
| Trickles one byte / 300ms forever | cut at the deadline | PASS (cut inside budget, nothing returned) |
| Headers then total silence | cut at the deadline | PASS |
| Deadline already passed | refuse before reading a byte | PASS |
| gzip cut off before its trailer | DecodeFailed, no partial page | PASS |
| Claims gzip, sends plain text | DecodeFailed | PASS |
| Complete gzip member + trailing junk | must still read | PASS |
| 6MB page | read, cut to the 2.5MB ceiling exactly | PASS |
| 6MB decompression bomb | ceiling counts decoded bytes | PASS |
guardedLookup returns only validated addresses and the socket connects to those, so the ordinary attack has no window. Fully closing it means pinning the resolved address and carrying the hostname separately for TLS. I did not do this, and I did not claim the inherited comment was wrong — it is conservative, which is the right direction to err.Inherited: detection, both values kept whole, disputed fields seeding nothing.
What I found: the panel showed both answers as two lines of text. The commit that built it said the owner would "pick rather than type" — both answers arrived, the picking did not. Settling one meant reading it, scrolling to find the matching question, and answering by hand, for the one thing on that screen that only the owner can do.
Now: each answer is a button with aria-pressed, in a named role="group" — the same shape the questionnaire uses for a choose-question, because a <label> cannot point at several buttons and a tint says nothing to a screen reader.
A tap records the value as theirs, not the winning source's. They read both and chose, which is what resolveClaims means by an owner's claim ending the matter. The agreeing source gets no credit — *"the website was right"* is a different claim from *"the owner decided"*.
A settled row stays on screen (removing the evidence they just used would leave them unsure it registered) and the heading counts what is left — *"1 of 2 still to settle"*.
Two key spaces reach that panel and are answered differently: question-keyed conflicts by answering the question, business_name by writing the name box. business_name is checked first, so a future question filling the same path cannot leave the box showing a stale name. A key with neither returns none and is left alone — a misplaced value is worse than an open disagreement, because the disagreement is visible and it is not.
States used, exactly as specified: CONFIRMED / LIKELY / NEEDS_CONFIRMATION / CONFLICTING. No invented numbers anywhere.
/blueprint, plus the pure assembler in src/lib/blueprint/ (112 assertions).
Rendered server-side against real data (temporary route, since removed). Verified on screen: mechanism + URL on every attributed line; a conflict showing both values and no pick; Thai text (คู่รักที่มาเที่ยวภูเก็ตครั้งแรก) rendering; the failed Instagram attempt shown with its machine reason private_account; and every number a real fraction — *7 of 20 lines*, *1 of 2 sources*, *13 things left*.
A structural test walks the entire object and fails on any number that is not a count with a stated denominator, and on any % in any string. That is checked by walking the data, not by reading the code, because the way a number reaches a screen is that somebody adds a field six months from now.
State: NOT_CONNECTED. Nothing is live. No Meta app, no token, no provider account. capabilities.instagram is off and the step says so plainly.
Access is a state with fixed wording (inherited, instagram-access.ts), and exactly one of seven may say connected:
NOT_CONNECTED · PUBLIC_PROFILE · CONNECTED · PERMISSION_REQUIRED · UNSUPPORTED · CAPTURE_FAILED · NO_EVIDENCE
A public read renders "Public profile analysis" and never "Connected Instagram Account" — asserted in the suite. The copy avoids the word even in the negative, since *"nothing was connected"* is read at a glance as *"connected"*.
Confirmed as still accurate: the three permissions (instagram_basic, instagram_manage_insights, pages_read_engagement), the Business-Manager ads_read/ads_management case, *"another Instagram Business or Creator IG User"*, read-only, and age-gated accounts excluded.
Two facts the previous pass did not have, and both change the decision:
graph.instagram.com) and *Instagram API with Facebook Login* (graph.facebook.com) — and the comparison table marks Business Discovery on the Facebook Login one and not the other. So the two Meta routes are not a fallback pair. Building the simpler owner-authorised route first buys nothing towards reading a prospect, and means doing the Facebook Page, the Meta app and the App Review a second time.4800 × impressions in 24h — impressions on our own account's content. A new Bulgar account has approximately none, so that formula would yield approximately nothing; being exempt from it is the only reason this route is viable for a studio with no following.Recorded as UNVERIFIED, not repeated: third-party write-ups describe a cap on unique accounts per rolling week. Meta's own reference does not state one. It is the kind of limit that decides whether this can serve onboarding at all — a question for whoever applies, not a number to plan around. The suite asserts the word UNVERIFIED is present so it cannot quietly become a number later.
Also, because it dates every older document: the Instagram Basic Display API was shut down entirely on 4 December 2024.
ROUTES_TO_LIVE holds this as data, not prose, so a handoff cannot paraphrase it into something more optimistic. VERIFIED_AT = '2026-08-25' is asserted present and well-formed, so "we checked" always carries "when".
Meta `business_discovery` — a Bulgar Instagram professional account linked to a Facebook Page · a Meta app with the Instagram product · a Facebook User token with the three permissions · Advanced Access via App Review (Standard reaches only accounts we own, which is none of the ones we want). *Blocked on: no Meta app exists.*
What no route will ever provide: personal accounts · private accounts · age-gated accounts · follower lists · audience demographics for an account we do not manage. Asserted in the suite, so a screen expecting demographics fails a test rather than shipping.
Nothing was scraped. No private or authenticated page was touched. No platform restriction was bypassed.
Website: invalid_url · blocked_address · blocked_redirect · too_many_redirects · http_404 · http_* · not_html · timeout · `undecodable` (new) · network · action_failed.
Every one carries a machine reason for intake_sources.error_note and a separate sentence for the owner that ends with what happens next — the step never dead-ends, and the questionnaire is always the fallback.
Instagram: the seven access states above, with retryable decided per state — paperwork and privacy are not retryable, a timeout is.
Blueprint: no database · not signed in · no brand · brand not locked · no draft · draft present but unsaved. Each says something different and true.
Before (my starting point after the fast-forward): 904. After: 1121. *(443 → 904 was the inherited chain.)*
| Suite | Before | After |
| dna | 99 | 103 |
| intake | 243 | 283 |
| composite | 26 | 26 |
| draft | 50 | 59 |
| flow | 25 | 37 |
| address | 70 | 70 |
| body *(new)* | — | 23 |
| evidence | 234 | 238 |
| facts | 82 | 82 |
| 75 | 88 | |
| blueprint *(new)* | — | 112 |
| Total | 904 | 1121 |
No test was weakened, skipped or deleted. Three assertions changed and each is a tightening, not a loosening. No eslint-disable, no rule turned off.
| Gate | Result |
npm test (1121 assertions) | PASS — 0 failed, 0 NOT_RUN reported |
npx tsc --noEmit | PASS |
npm run lint (blocking) | PASS — clean, nothing suppressed |
npm run build | PASS |
/, /onboarding, /blueprint serve 200 | PASS |
| SSRF refusals, 24 cases live | PASS |
Live fetch + extract (example.com, 575KB Wikipedia, PADI) | PASS |
| Blueprint rendered server-side with real data | PASS |
npm run test:db (21 persistence checks) | NOT_RUN — no database, by design |
| Supabase round trip for the locked blueprint | NOT_RUN — no database |
Client sessionStorage blueprint path in a browser | NOT_RUN — no browser available |
| Thai typography judged by a Thai reader | NOT_RUN — needs a person |
| Instagram integration | NOT_PRESENT |
| DNS rebinding fully closed by address pinning | NOT_PRESENT |
audience.primary, offer.main_offer). The lookup returns undefined, which is indistinguishable from "no question asks this" — every value the owner typed on those lines would have silently lost its "you told us". Caught by a test, not by the compiler. The join is now asserted line by line.body.ts rather than weakening the guard or mocking.react-hooks/set-state-in-effect. Rewritten with useSyncExternalStore, matching the pattern 5693432 established. Nothing suppressed._-prefixed folders. Renamed, used, removed.NOTES.md warns. It cost me a 404 on a route I had just built. fuser -k 3210/tcp.docs/16-design.md is the project's stated visual law, and CLAUDE.md says to read it before any UI work. The existing system is warm cream with green and amber — light surfaces, generous whitespace, no sharp corners, contrast ratios measured with the product's own contrastRatio(). It is emphatically not a black/red war room, which is the constraint the brief actually names. Rewriting the palette unilaterally would overwrite a documented decision to satisfy an adjective. /blueprint is built to the existing law and invents no new colour. If the violet direction is wanted, it is a change to `docs/16-design.md` first — a human's call.source_mechanisms column, which is a migration.business_discovery — ask Meta during App Review. It may decide viability.blueprintFromLockedRow is tested as a pure function; the Supabase round trip is NOT_RUN, and so is npm run test:db.sessionStorage read itself has not been exercised.CLAUDE.md says Ivan cannot either.