# Report packet and evidence contract (contract 2.0.0)

`node skills/optbrew/scripts/cli.mjs report --input <packet.json> --output-dir <new-directory>` writes `report.json`, `report.md`, `fixes.md` and `report.html` (the page shows the full-steps runbooks, because a packet does not record the guidance level). It makes no network or paid calls. It refuses to replace an existing report. A report is a local audit artifact; a local passing verification does not establish deployment, indexing, traffic, ranking, citations, or business impact.

`ReportPacket` retains the approved `run_context`, raw `baseline.input`, optional `after.input`, their `score()` results, `compare()` result, metrics, findings, changes, costs, remaining work, and source identity. `evidence_root` points to the run directory. The CLI resolves a relative root against the packet's directory. Each `evidence_index` item has `{ref,sha256,kind,run_id,source_ref?,source_url?}`. Every indexed file is reopened, checked against its SHA-256, and contained by the resolved run directory. The context file at each phase's `run_context.evidence_ref` must contain exactly that run context.

An applicability `evidence_ref` points to JSON `{run_id,target,protocol_hash,approved_by,decisions}`. Each decision is `{scope:"family",family_id,condition,reason}`, `{scope:"url",normalized_url,condition,reason}`, or `{scope:"unit",unit_key,condition,reason}`. A matching record identifies the decision and its approval context; it does not independently prove the underlying site fact. Missing or unrelated decisions are contract errors. Unknown checks remain in the denominator.

Collector phases use `audit_snapshots:[{phase:"baseline"|"after",audit_ref,context_ref}]`, one Task 5 evidence index per phase. A baseline-only packet may use legacy `audit_ref`. The audit's run/context, observation file, collection file, module output, artifact `evidence_ref` and non-null raw body hash must match that phase. A bodyless DNS/timeout artifact has `content_hash:null`, points to the collection file, and remains `unknown`; it is not compared to the collection file hash as though it were a body. Same-URL before/after observations must use their own snapshot and raw ref.

Owner export metrics replay through `normalizeImport()` from evidence JSON `{kind:"native_export",run_id,provider,rows,metadata}`. `run_context.protocol.target_identity` is a comparison-relevant, hashed field. It contains `{target,native_properties:[{provider,property,hostname?,path_prefix?,evidence_ref}],panel?}`. Each native mapping ref points to a run/target/provider/property/approval record. Known row hostname and path must fit its approved mapping. The native metadata supplies `collected_at` with offset and `last_complete_date` in the report timezone; a pair needs complete, adjacent 28-day windows, identical definition, provider definition, full sample selection, filters, panel and timezone. Missing completeness or a changed cohort yields no delta. Imported provider origin and completeness remain attributed to the owner's export.

AI panel metrics replay through `measurePanel()` from `{kind:"panel",run_id,provider_origin,panel,responses,evaluations}`. Frozen `target_identity.panel` is `{target_entity,target_domains,competitor_entities,evidence_ref}` and has a matching approved mapping file. Questions, engine, surface, model, search use, locale, region and target identity must match the frozen run. Every response has `response_ref` (indexed redacted provider response) and `extraction_ref` (indexed `{run_id,response_id,raw_ref,raw_sha256,normalized_response}` receipt). Every evaluation has `evidence_ref` for an indexed `{run_id,response_id,approved_by,evaluation}` receipt. Evaluable citations have `source_evidence_ref` whose index entry `source_url` equals the cited URL. Evaluable claims have `fact_id` and `source_evidence_ref`, and their `panel.fact_list_ref` opens an approved `{run_id,target_entity,approved_by,facts:[{fact_id,...}]}` list. Missing material is a contract error. These links preserve attribution; they do not independently authenticate a provider or prove factual truth.

The persisted `CostLedger` snapshot is indexed at `costs.evidence_ref`; unknown settlement, observed overrun and `host_policy` limitations stay visible. Do not include secrets, account tokens, personal records, or private card text in a packet or evidence directory.

Findings merge only when check, unit and normalized problem text agree. Every contributing source remains on the merged finding. A finding must match a failed or unknown check observation. The curated remediation catalog chooses only an applicable, directly relevant mechanism; official sources lead the evidence order. A remote finding has a proposed change location and validation method but remains `proposed_only`. A `ChangeRecord` receipt binds run ID, change ID, target, path, environment, after hash, status, and passing checks whose indexed changed bytes have that hash. Production also requires a deployment receipt for the same target/path/hash and an explicitly linked passing `after` observation. Every required linked change must pass; local pass plus production pending remains pending. Receipts and hashes establish this local evidence chain, not external deployment truth. No SEO gain is predicted. Hypothetical readiness changes belong in a separately marked scenario, never in `after`.

For `verification.status:"passed"`, the indexed receipt has `{run_id,change_id,target,path,environment,after_hash,status,checks:[{id,status:"passed",observed,evidence_ref,after_hash}]}`. Each check's ref opens the changed bytes. Production adds `deployed:true`, `deployment:{target,path,after_hash,evidence_ref}`, and `observation_id` / `observation_evidence_ref` in both receipt and `ChangeRecord.verification`. The deployment evidence file repeats run, target, path, hash, environment `production` and `confirmed_by`. The matching `after` observation must resolve the linked finding in a run context with the same target and `production` environment. Its indexed evidence entry also identifies `phase:"after"`, that target and `environment:"production"`; absent or different attribution leaves the finding unverified. Other comparison-condition changes may still yield `not_comparable` without invalidating a same-target verification. A pending receipt cannot combine with another passed change to finish a finding.

Collector verdicts are replayed, not trusted. For each collector-sourced `index-policy`, `document-title` and `search-crawl-policy` observation the report reopens the preserved raw bytes of the same phase and rebuilds the verdict with the code the audit used. Index directives come from the HTML `<head>` meta tags (comments and script, style, textarea and title text are ignored) and the collected `X-Robots-Tag` header. The generic `robots` directives combine with the target engine's own (`googlebot` for Google, `yeti` for Naver); a header prefixed with another crawler name does not apply, and a crawler prefix scopes only the header line that carries it. Meta directives may be separated by commas or semicolons. A robots meta tag inside `<template>` or `<noscript>` is conservatively counted as a noindex directive. A present title replays as `unknown` because uniqueness and descriptiveness need separate evidence; a missing title replays as `fail`; reviewed duplicate-title or semantic verdicts from other evaluators are not rejected for having a title. A crawl-policy verdict needs the declared `robots` auxiliary artifact, its content hash, the page's final URL and a target engine, then recomputes `pass` or `fail` from the robots bytes; a robots file answered with a 4xx other than 429 replays as `pass` from its stored status. Every collected response is replayed from its hashed `*.response.json` record: the collection entry must equal the record, the final URL must follow the recorded redirect chain (no redirect means the final URL is the normalized request URL), and header lines come from the record, so index every `responses[].evidence_ref` from the audit index with `source_ref:"optbrew:collector"`. A selected page must be either collected or listed in `skipped`, and an indexed collector artifact requires its baseline audit snapshot. Every observation the collector plans for a selected page (fetch, title, index and, for Naver with a disallowing declared robots file, crawl policy) must be present in the packet and in the saved observations; a missing one is an error, never a silent pass. A redirected URL binds its requested unit to the artifact first; the module output must describe that artifact's final URL (`requested_url` and `redirect_chain` stay with it). A header-only `noindex` finding uses the same HTML plus header replay as the observation and must belong to the finding's own unit.

`verified_local` uses the production binding: the change and its receipt name an `observation_id` and `observation_evidence_ref` for a passing after observation that the collector produced (so the audit replay covers it); that raw evidence entry is labelled `phase:"after"`, `environment:"local"` and the run target, and the after context is `local` for the same target. A byte-hash receipt or an agent-written observation alone leaves the finding `modified_unverified`. For `verified_production` the finding carries `resolution` (observation ID, evaluator type, source) and the Status cell reads `resolved by <evaluator_type> observation from <source_ref>`, which names the observation that resolved the finding (only a `code` observation from `optbrew:collector` can, because a model judgment is bound to spans of the stored judge queue and never stands for a code-judged check); the production gating rules are unchanged. Native path mappings match whole path segments: the exported path starts with a single `/` (protocol-relative paths and absolute URLs on another host are refused) and equals the approved `path_prefix` or continues after a `/`.

Collector verdicts for the families below come from one derivation, `scripts/core/derive.mjs`, which the audit runs on the live responses and the report runs again on the hashed raw evidence. Every derived observation must be in the packet and in the saved observations, and each packet observation must match its derivation in state, credit and evidence reference; an extra or missing collector observation is an error. The families: `fetch-response`; `index-policy`; `document-title` (missing or duplicated across the selected pages fails; with at least two audited pages a unique title that is not only the site name or a generic word passes, and otherwise it stays `unknown`; only pages meant to be indexed count. A page whose canonical link names the same page without the query string or fragment (same site with `www.` and http/https folded, same path; a relative canonical is resolved against `<base href>` when the head has one) is a variant of that page, and the variants and the page are one document that never duplicates itself; its pass reason says the title is shared only with query variants of the same page. A canonical to the home page, to another path or to another site leaves each page its own document, so those pages still duplicate each other); `search-crawl-policy` for Google and Naver and `ai-crawler-policy` for ChatGPT, Claude and Perplexity, each judged on the declared robots file for the target engines with a token in `ai-crawlers.json`; `canonical-consistency` (a redirect chain of two or more hops, or a canonical target that returns an error, fails; a self-referencing or reachable canonical within one hop passes; the collector fetches in-scope canonical targets within the request limit); `discovery` (a selected public page that no collected page links to and no reachable declared sitemap lists fails when the crawl was complete and at least two pages were collected; a sitemap index or an incomplete crawl leaves it `unknown`; an unlinked page is `unknown` when another collected page's own bytes (inline scripts included) name its path as a whole path, or when a script-only page loads a same-origin script file that was not collected, unless a fetched sitemap that lists other pages leaves it out (an inline script that builds the URL from pieces can still give a false orphan); entity-encoded hrefs are decoded and `index.html` equals its directory); `structured-data-truth` (a live JSON-LD block that does not parse fails; blocks inside HTML comments and empty placeholder blocks are ignored; parsing alone never passes); `naver-markup` (Open Graph, description, canonical, viewport, title repetition, CSS or JS blocked for Yeti); `conversion-path` for a run with the conversion goal (one site verdict: a contact route on any audited page, raw or rendered); and, for runs that enable them, `fact-consistency` and `entity-identity`. Fact consistency compares name, address, phone and founding year across the selected pages. Values come only from Organization or LocalBusiness JSON-LD, `tel:` links (counted only on a page with no labelled or JSON-LD phone, so a mobile inquiry link never contradicts the landline) and explicitly labelled fields (`Phone:`, `Tel`, `Address:`, `Founded:` and Korean equivalents); prose, dates and numbers elsewhere are never read as facts, and a fax or registration number is not a phone. A national number written with spaces only (02 555 0142) is read in a label, and so are Korean nationwide representative numbers (15xx, 16xx or 18xx plus four digits). Addresses are compared after unifying street abbreviations, dropping postal codes and a trailing country, and treating a street address with or without city detail as the same. A page is a separate entity and is not compared only on structural evidence: JSON-LD `branchOf`, `parentOrganization` or a different `@id`; a branch-list URL path (`/branch/`, `/branches/`, `/locations/`, `/stores/`, `/지점/`); or the word branch, 지점 or 지사 in its title or first heading together with an address of its own that differs from the main entity's. A title word alone does not hide a wrong phone. A reference value needs at least two pages and a unique majority; a page that disagrees, or whose JSON-LD disagrees with its own visible text, fails; a tie or a single page stays `unknown`. Without confirmed facts this is consistency between pages, not accuracy; a fact the owner confirmed at the start (`protocol.confirmed_facts`) is the reference of its type instead, and a page value that differs from it fails. `entity-identity` (site unit) fails on conflicting names, Organization JSON-LD without `sameAs`, or no Organization JSON-LD on the home page while other pages have it. The `robots.txt` Sitemap line, og:image size, hreflang and JavaScript-only navigation are not scripted. Audit evidence from 0.1.2 or earlier has no hashed failure records and lacks the 0.1.3 discovery and fact observations, so report replay refuses it with an "older optbrew release" error; run the audit again with the current release and build a new packet. Every audit also writes `ledger.json`: one entry per queued or rejected URL with its request count. Index it with `source_ref:"optbrew:collector"`. Replay checks that the skipped list, `limit_reached`, `request_count` and `evaluated_count` equal the ledger, that each response entry matches a hashed response record (requests = redirect hops + 1), that each failed fetch matches a hashed failure record (`*.failure.json`, listed in `failures`; requests = answered redirect hops + at most one attempt that got no response), that a URL appears in only one entry, that a failed fetch or a request-limit skip belongs to a URL with provenance (selected, auxiliary, or linked from a collected page), that the total never exceeds the request limit and a limit is claimed only when the recorded requests use all of it, and that a selected page is skipped only for the request limit. Index every `*.failure.json` file with `source_ref:"optbrew:collector"`, as for `*.response.json`. The audit index also lists the modules that ran; every listed module must have its output in `modules.json`. All derived families above are computed from the collector evidence whether or not a module ran.

**Composed once.** `core/report-compose.mjs` builds one `Composed` object from the checked report data, and `report.json`, `report.md` and `report.html` only render it: `json.composed = { headline, fixes, completeness, remaining, conditions, follow_ups }` comes before the evidence it was built from, and no score field appears before `fixes`. `headline` is the one-line conclusion `{ open, blocking, already_fixed, text, not_indexed }`; `not_indexed` is `{ count, pages, message_key, message_values, text }` when the run treated pages as deliberately not indexed (noindex and absent from the sitemaps it read), else null, and `report.md` prints it under the result line (`색인 제외:`) and `report.html` in the top box, so an owner sees a noindex that was hidden by mistake without opening the run conditions; with no open fix and any latest-phase headline other than `sufficient` it says nothing failed in what was checked but the check is incomplete and points to the completeness section (얼마나 확인했나), where the cause is shown, never that nothing is left to fix. `fixes` (each with its `change_paths`, `where` (the place to fix), `priority_label` and `status_label`) are the findings sorted open before resolved, then blocker first, then priority (P0 first, an unknown priority last), then the problem seen on the most pages. `completeness` has, per profile, `pass_rate_in_measured` (the score: the pass rate within what was measured), `coverage` and `coverage_without_cwv` (coverage if the three field-data checks were left out), all from the latest phase (the after phase when there is one), plus `before` and `after` (`headline`, `coverage`, `missing_bounds`, `readiness_status` of each phase), `difference` and `between_measured_subsets`. `remaining` lists what is still blank in the latest phase: `{ item, fills, runbook_ids }` for each unknown reason a runbook can fix and each start item the user did not answer (`answered_runbooks` in the packet lists those answered). `item` says what is missing and what it adds; the export file adds actual outcomes, not a score. A user who has a Google key (or Chrome) is not offered the runbook for getting it; the Google key item names the key-saving runbook for the probe's harness. `follow_ups` is the remaining-work list (the checked `follow_ups` rows, composed here so the HTML only formats them). `conditions.lines` is the one list of label and value pairs both formats print under the run conditions. `conditions` holds the start probe (of the latest phase's run context, the same phase as the blanks), the blocker counts `blockers: { before, after }` (`{ count, items, core_unknown }`, `after` null without an after phase), the checked model judgments, the lab Core Web Vitals (`lab_cwv` in the packet, reference only, never a score input) and unscored `notes`: the JavaScript note (Naver and AI crawlers may not run JavaScript) appears only when a page passed `render-content` after rendering. Report text is Korean (R64): every label, headline sentence and note in `report.md` and `report.html`; JSON keys and ids stay English. In the Markdown the order is: result line, 먼저 고칠 것 (fixes), 얼마나 확인했나 (completeness), 아직 비어 있는 곳 (still blank), 실행 조건 (run conditions), 남은 일 (remaining work), 실제 결과 (actual outcomes), 모델 판단 (model judgments). Terms are fixed: 크롬 and 자바스크립트, never Chrome or JavaScript spelled in Korean text. Finding messages, remediation text, remaining-work rows and cost notes are Korean too (R65): a finding keeps its English `message` (the text findings are grouped by, and the text in the golden files and replay) and carries `message_key` and `message_values`; the Korean sentence for a key is in `references/messages.ko.json`, and `composed.fixes[].text_ko` (`message`, and `remediation` with `mechanism`, `validation`, `effort`, `cost` and `location_hint`: for each of the first four the message key's own steps `<key>.mechanism`, `<key>.validation`, `<key>.effort` and `<key>.location_hint` win, then the Korean text of the chosen catalog remediation (`remediation.<id>.<field>`), then its own words; `cost` comes only from the catalog) and `composed.follow_ups[].text_ko` hold it for the renderers. A key with no entry, such as a model's finding, shows its own words. A remaining-work row that a host writes itself (a caller of `buildReport` outside the run path) has no key, so it must be written in the user's language: the report prints it as it is. Replay binds the key and values of a finding to the failed verdict that states the problem, as it does the English message.

**HTML report.** `core/report-html.mjs` formats the composed report as one self-contained page (inline CSS and SVG, no script, no outside file, a policy line that refuses any outside request, light and dark by `prefers-color-scheme`, readable at phone width). The top three sections are the fixes, the completeness and the blanks, in few words; every value from the site or the user is escaped. Each blank item opens its runbooks in `<details>` at the guidance level of the start answers as plain lists. A step has a small schematic drawing only where the runbook names the zone with a trailing tag such as `{화면: 주소창}` (zones: 주소창, 화면 위쪽, 왼쪽 메뉴, 화면 가운데, 새 창, 터미널); a zone is never guessed from the words of a step (R66), and no shipped runbook carries a tag today. Every coverage the report shows is the floor to a tenth that the 80% threshold is judged on. Each profile card shows one verdict word, a meter and one plain sentence, with the long line folded; 남은 일 (`follow_ups`) and the run conditions are folded sections. The audit writes the page as `report.html` beside the other two and prints its path in `reports.html`.

**Fix list.** `core/report-fixes.mjs` writes `fixes.md` beside `report.md`, built only from `composed.fixes`; it looks nothing up again, so a replay of an old packet gives the list the run gave. It lists the fixes that are still open (not `verified_production`), in the order of the report, one section per fix headed by its `finding_id`, with its problem sentence, every page it affects (the report table names the first five), the method (with the official source when the catalog chose one), how to check it, and where to fix it. A fix confirmed in production is counted in one line and left out. The text goes through the same escape as `report.md`, so a tag a sentence names reads as `&lt;title&gt;` in the raw file. It is written in the same all-or-none step as the other reports, so an existing `fixes.md` stops the report, and the audit prints its path in `reports.fixes`.

Other `report.json` fields: `findings` repeats `composed.fixes` for readers of earlier reports, `model_judgments` stays the checked judgment list, and `follow_ups` (called `remaining` before 0.2.0) holds the per-finding follow-up rows shown under Remaining work. The run adds its own rows: an unusable start read, an owner export it could not use, and, when Naver is a target engine, no Naver export was read and the collected start page has no live `naver-site-verification` meta tag, one row (`follow-up.naver-ownership`) that asks the owner to look for the site in the Search Advisor site list (steps 1 to 4 of the `naver-searchadvisor-export` runbook). Naver ownership is never scored: a site verified by an uploaded HTML file has no tag, so the row changes no verdict and no score. `composed.remaining` is a different list: the blanks.

The Completeness section opens with a Headline per profile. At least 80% measured coverage and a verdict on every core access and indexing check print the score with its coverage; below 80% the headline is `insufficient coverage`, and with no verdict on a core check it is `insufficient core checks`, with the measured-subset score and the missing-data bounds, and a phase that was not audited (its `RunContext.audited` is not true) shows `not audited` instead of a score when the score would otherwise print, and says so beside any other status (`audited: false`); each phase headline in `composed.completeness[]` carries `status` (`sufficient`, `unaudited`, `insufficient_coverage`, `insufficient_core`), `coverage_exact`, `headline_score` (null when withheld), `measured_subset_score`, `coverage` and `missing_bounds`. One rule shows every coverage in the three formats (`baseline.scores[]` and `after.scores[]` are the replayed ScoreResult objects with their own one-decimal rounding; the coverage the report shows is `composed.completeness[].coverage`): the floor to a tenth of the exact value, the value the 80% threshold reads. `coverage_exact` keeps the exact value, and `completeness[].coverage_without_cwv` follows the same rule. The conditions list and the Model judgments section say how many units the model declared not applicable, for each family (`composed.conditions.model_not_applicable`), apart from the coverage those units leave out, and each profile card of the HTML says how many units that is. Under Model judgments, `composed.conditions.question_coverage` lists each question of the fixed panel with the pages that answer it.

Both formats show the same tables (`json.tables`): fixes (`remediation`), completeness (`score`), actual outcomes, remaining work and model judgments. Score arithmetic comes from `score()` and `compare()`. Actual outcome pairs require the same native metric definition, filters, sample selection and panel, plus two adjacent complete 28-day windows. An absent or ambiguous value stays unknown. Markdown escapes pipes, HTML and line breaks in evidence and titles. `data_class:synthetic` labels fixture reports and cannot be presented as observed production performance.

**Model judgments.** The audit stores the model's work list as a `judge-queue` evidence file, listed with its hash in the audit index section `judge_queue` (candidate text spans per page and per content family; index the file as `kind:"judge-queue"`). A `host_model` observation has `source_ref:"optbrew:model-judgment"`, cites the raw evidence of the page its unit names, and, with a state other than `unknown`, carries `evidence_span: [{ evidence_sha256, start, end }]`: one UTF-8 byte range (end excluded) per cited span, each starting and ending on character boundaries. Report replay checks that the evidence is the audit raw entry of that page, that each range has that entry's hash, lies inside the bytes and is exactly a candidate of the stored queue, and rejects a tampered span or queue. Replay also recomputes the candidate text runs from the raw bytes, so a forged queue cannot widen them, and reads the queue only from evidence of kind `judge-queue`. A model's `not_applicable` carries one of the family's registry conditions in `applicability_decision` (its evidence is the verdict's own page evidence) and cites spans like any other verdict; replay refuses a condition the family does not have and a span the queue did not offer. An answer-clarity verdict other than unknown records the panel question it answers in `measurement.question_id`, and a pass or partial one may list the other questions the page answers in `measurement.also_question_ids` (other, distinct panel questions; replay checks them). A failed model verdict records one `problem_messages` entry, the catalog key `model-fix.<family>[.<basis>]` with the cited text as `quote`; replay recomputes it from the family, the basis and the cited spans, and the report words the problem, the fix method, the effort and the check from the catalog (the model's own reason stays in the verdict as evidence). Allowed pairs: `code` with `optbrew:collector`, `host_model` with `optbrew:model-judgment`, and `code` with `optbrew:judgment-derived`. A content family (per-page, no code check) accepts only the model pair; a code-judged family refuses any host_model observation, and beside an audit snapshot accepts only the code pair, which is re-derived. Two units are exceptions. `entity-identity|site` may carry the model pair when code gave no verdict for it (the home page has no Organization JSON-LD); replay then reads the home page's raw entry and refuses a model verdict next to a code verdict. `question-coverage|site` is the derived pair: the number of fixed-panel questions that an audited page answers (a pass or partial answer-clarity verdict naming the question in `measurement.question_id` or `measurement.also_question_ids`, which replay checks against the panel, counted once per question) over the panel length, with the list in `measurement`; replay computes it again from the answer-clarity verdicts and the panel and refuses any other figure. Observations from other sources stay what they were: unverified inputs of a packet without an audit. A checked judgment is listed in `model_judgments` and in a "Model judgments" section labelled 근거 확인됨, 판정은 모델: the evidence is verified, the verdict is the model's.

**Audited phases.** Each phase's `RunContext` carries `audited: true` when the phase was audited (written with the context at run start, so it is hashed run evidence). Replay refuses a packet whose audit snapshot for a phase exists exactly when its context does not say `audited`. A phase without an audit accepts only outside or synthetic inputs: the collector and model sources are refused there, no finding can reach `verified_local` or `verified_production`, and its score readiness is never `measured` (it reads `unverified`). `verified_*` needs an after observation that is `code` with `optbrew:collector`, re-derived against the after snapshot.

Every `ChangeRecord` carries `analysis: { tools, queries, self_analysis }`: the tools and queries used to reach the change, and whether the agent analysed the page itself.
