{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bugpacker.com/report.schema.json",
  "title": "Bugpacker report.json",
  "description": "The machine-readable source of record inside a Bugpacker package. BUG-REPORT.md and BUG-REPRO-STEPS.txt are rendered from this and carry no information that is not here. Reporter assertions live under `reported`; everything else was measured by the capture. schemaVersion changes only when the shape breaks a consumer; the tool version moves independently.",
  "type": "object",
  "required": [
    "schemaVersion",
    "reportId",
    "tool",
    "fingerprint",
    "createdAt",
    "page",
    "recording",
    "reported",
    "reporting",
    "findings",
    "steps",
    "counts",
    "files",
    "archive"
  ],
  "properties": {
    "schemaVersion": {
      "type": "integer",
      "const": 9,
      "description": "Contract version for consumers. v9 added the required page.startUrl, added `start` and `history` to recording.navigationTrail[].kind, and redefined page.url as the page at export time rather than at start. v8 added the required recording.navigationTrail. v7 added the capture-scope-limited finding code. v6 added the required files[].activeContent.framesNeutralised and changed the meaning of files[].activeContent.frames. v5 added the `observation` finding class, the first-party-undetermined code and files[].lossy. v4 added the required `reporting` object. v3 made findings[].audience, findings[].shownToReporter and steps[].ordinal required and added uncaught-errors-observed."
    },
    "tool": {
      "type": "object",
      "required": [
        "name",
        "version"
      ],
      "properties": {
        "name": {
          "type": "string"
        },
        "version": {
          "type": "string",
          "description": "Semver of the extension, not of this format."
        }
      }
    },
    "fingerprint": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$",
      "description": "Stable across reports of the same defect. Derived from the host, a path with its identifiers collapsed, the marked element and the error text, never the timestamp or per-visit values. Intended as a deduplication key. Stable across captures of the same condition: createdAt, the recording duration and any per-visit path segment are excluded from the input."
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "page": {
      "type": "object",
      "required": [
        "url",
        "host",
        "path",
        "title",
        "startUrl"
      ],
      "properties": {
        "url": {
          "type": "string",
          "description": "The page at export time. On a site that changes its URL client-side this is the end state, not where the reproduction begins, see startUrl. Originates in the captured page: untrusted."
        },
        "startUrl": {
          "type": "string",
          "description": "The URL when recording started, which is where a reproduction begins. Identical to the `start` entry of recording.navigationTrail and derived from the same value, so the two cannot disagree. Equal to `url` unless the site changed its URL during the recording. Untrusted."
        },
        "host": {
          "type": "string"
        },
        "path": {
          "type": "string"
        },
        "title": {
          "type": "string"
        }
      },
      "description": "url and title originate in the captured page. Treat as untrusted input."
    },
    "recording": {
      "type": "object",
      "required": [
        "startedAt",
        "durationMs",
        "navigations",
        "reloadedAtStart",
        "navigationTrail"
      ],
      "properties": {
        "startedAt": {
          "type": "string",
          "format": "date-time"
        },
        "durationMs": {
          "type": "integer",
          "minimum": 0
        },
        "navigations": {
          "type": "integer",
          "minimum": 0,
          "description": "How many document navigations occurred: the navigate and reload entries of navigationTrail. A URL changed by the history API is not counted here, because no document loaded, a page that paginates client-side produces a recording whose start and end URLs differ with navigations 0, and that is correct."
        },
        "navigationTrail": {
          "type": "array",
          "description": "Every URL this recording held, in order, beginning with where it started. `start` is the URL at recording start; `navigate` and `reload` loaded a document; `history` is a pushState or replaceState, where the address changed and nothing was reinstalled because nothing needed to be. Only navigate and reload are counted by `navigations`, so trail length and that count deliberately differ. The three kinds have different implications for whether the page-world recorders survived: Chrome’s one-tab grant covers same-origin navigation and reload, a history change is not a navigation at all, and a cross-origin navigation is the only real boundary. steps[] carries navigations too, with the URL shortened to a path, which is the part that cannot answer the question.",
          "items": {
            "type": "object",
            "required": [
              "at",
              "url",
              "kind"
            ],
            "properties": {
              "at": {
                "type": "string",
                "format": "date-time"
              },
              "url": {
                "type": "string",
                "description": "Scrubbed like every other URL in the package."
              },
              "kind": {
                "type": "string",
                "enum": [
                  "start",
                  "navigate",
                  "reload",
                  "history"
                ]
              }
            }
          }
        },
        "reloadedAtStart": {
          "type": "boolean"
        }
      }
    },
    "steps": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "index",
          "ordinal",
          "kind",
          "detail",
          "text",
          "verb",
          "offsetMs",
          "repeats",
          "at"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "minimum": 1,
            "description": "Position in the whole recorded sequence, instructions and evidence together. This is what `screenshots[].afterStep` points at. It is not the repro-step number, see `ordinal`."
          },
          "ordinal": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "description": "The number this step carries in the repro list, or null when the entry is evidence rather than an instruction. Render only the non-null ones as a numbered procedure: a list in which item 7 is \"Console error: …\" is not something a person can follow. Non-null exactly when kind is user and verb is not omitted."
          },
          "kind": {
            "type": "string",
            "enum": [
              "user",
              "system"
            ],
            "description": "user steps are instructions a person can follow; system steps are evidence observed between them."
          },
          "detail": {
            "type": "string",
            "enum": [
              "action",
              "navigation",
              "error",
              "network"
            ]
          },
          "text": {
            "type": "string",
            "description": "Rendered English. Convenient, never the only way to read a step."
          },
          "verb": {
            "type": "string",
            "enum": [
              "clicked",
              "typed",
              "changed",
              "submitted",
              "pressed",
              "scrolled",
              "navigated",
              "reloaded",
              "error",
              "request",
              "omitted"
            ]
          },
          "targetSelector": {
            "type": [
              "string",
              "null"
            ]
          },
          "targetLabel": {
            "type": [
              "string",
              "null"
            ]
          },
          "key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Structural keys only: Enter, Tab, Escape, arrows. Character keys are never recorded."
          },
          "offsetMs": {
            "type": "integer",
            "minimum": 0
          },
          "repeats": {
            "type": "integer",
            "minimum": 1
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "Absolute time, so steps and HAR entries share one basis."
          }
        }
      }
    },
    "traceIds": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Server request ids read from response headers, as \"header: value\"."
    },
    "environment": {
      "type": [
        "object",
        "null"
      ],
      "description": "Null when the page could not be inspected at stop. Individual fields may be null or empty where the browser does not expose them; appVersion and frameworks are frequently null and [] respectively."
    },
    "element": {
      "type": [
        "object",
        "null"
      ],
      "description": "The element under the middle of the marked region, whatever the user pointed at, which may be the error message or a container rather than the control. Present only when a region was marked. outerHTML originates in a page that is not ours: treat as untrusted input and sanitize before rendering. `relatedControl` names the form control the marked element is about when it is not itself one; its `how` says whether the page declared that link (aria-errormessage, aria-describedby, label-for) or the recorder inferred it (same-container, nearest-invalid), and its `siblings` says how many controls the inference chose between. Do not present an inferred link as an association the page made, and where siblings > 1 prefer stating the shape, 'contains four controls, of which Phone is the only invalid one', over naming one as though the page had."
    },
    "formState": {
      "type": "object",
      "required": [
        "total",
        "invalid",
        "duplicateIds"
      ],
      "properties": {
        "total": {
          "type": "integer",
          "minimum": 0
        },
        "invalid": {
          "type": "array",
          "items": {
            "type": "object",
            "description": "A form field the browser or the site marked invalid. Never contains the value entered.",
            "properties": {
              "selector": {
                "type": "string"
              },
              "valueLength": {
                "type": "integer"
              },
              "valueShape": {
                "type": "string",
                "description": "Characters masked by class, e.g. \"(NNN) NNN-NNNN\"."
              },
              "valueFacts": {
                "type": "object",
                "description": "Facts derived from the value so a reader can judge whether it should have been accepted, without seeing it. Keys vary by field type: digitCount, nanpShaped, e164Shaped, reservedRange, invalidAreaCode, invalidExchange for telephone fields; atCount, localLength, domainLength, domainHasDot, tldLength for email; digitCount, letterCount, numericOnly otherwise. hasEdgeWhitespace and length apply to all.",
                "additionalProperties": {
                  "type": [
                    "string",
                    "number",
                    "boolean"
                  ]
                }
              },
              "describedBy": {
                "type": "string",
                "description": "Text of the element the field points at with aria-describedby or aria-errormessage."
              },
              "duplicateId": {
                "type": "boolean"
              },
              "valid": {
                "type": "boolean",
                "description": "The browser's Constraint Validation verdict. A site with its own validator leaves this true and marks the field with aria-invalid instead."
              }
            }
          }
        },
        "duplicateIds": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Selectors matching more than one element. Invalid HTML, and a common cause of a validator binding to the copy the user is not typing into."
        }
      }
    },
    "counts": {
      "type": "object",
      "description": "Event totals. errors is consoleErrors plus uncaughtErrors plus CSP violations. networkBlocked counts requests that were expected to return a response and did not, usually an ad blocker rather than a defect, and is deliberately separate from networkFailures, which are requests that reached a server and came back 4xx or 5xx. networkBlocked has two components and one deliberate exclusion. The components: instrumented requests that failed with no status, which appear in network.har at status 0 with _bugpacker.ok false; and subresource load failures (script, img, link), which do not appear in the HAR at all because the HAR is built only from instrumented requests, and are listed in network-errors.log instead. The exclusion: a navigator.sendBeacon call returns a boolean and never a response, so a beacon structurally has no status and was never blocked. Beacons appear in the HAR at status 0 like anything without a status, and _bugpacker.ok is the discriminator, true for a beacon that was accepted, false for a request that failed. So networkBlocked equals the count of HAR entries at status 0 with _bugpacker.ok false, plus the resource-error lines in network-errors.log. Reading every status-0 HAR entry as blocked over-counts by the number of beacons. Read all of these together with manifest.json's capture block, including its scope line: a zero is only meaningful once you know what was running and which pages were in scope.",
      "additionalProperties": {
        "type": "integer"
      }
    },
    "redaction": {
      "type": "object",
      "required": [
        "total",
        "distinct",
        "byCategory"
      ],
      "properties": {
        "total": {
          "type": "integer",
          "minimum": 0
        },
        "distinct": {
          "type": "integer",
          "minimum": 0
        },
        "byCategory": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "category": {
                "type": "string",
                "enum": [
                  "email",
                  "card",
                  "ssn",
                  "iban",
                  "jwt",
                  "aws-key",
                  "bearer",
                  "url-credential",
                  "query-secret",
                  "header-secret",
                  "phone",
                  "ip",
                  "identifier",
                  "secret"
                ],
                "description": "identifier covers stable per-user and per-session ids: any 8-4-4-4-12 hex value, and any value under a key named like visitorId, sessionId, userId, deviceId and their relatives. The key survives so the log is still readable. ip covers public addresses only: private, loopback and link-local addresses are kept deliberately, because they identify infrastructure rather than people. secret is the catch-all for long opaque runs claimed by no more specific rule."
              },
              "values": {
                "type": "integer",
                "description": "Distinct original values replaced under this category."
              },
              "occurrences": {
                "type": "integer",
                "description": "Total replacements across every file in the package."
              }
            }
          },
          "description": "One entry per category that matched. Categories with no matches are absent, so an absent category means nothing was replaced under it, not that the detector for it was off. Redaction is always on and cannot be disabled."
        }
      }
    },
    "primaryScreenshot": {
      "type": [
        "string",
        "null"
      ],
      "description": "The image to use as the issue thumbnail. Null when the user marked no area."
    },
    "screenshots": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "file",
          "label",
          "offsetMs",
          "afterStep"
        ],
        "properties": {
          "file": {
            "type": "string"
          },
          "label": {
            "type": "string",
            "enum": [
              "start",
              "marked",
              "end"
            ]
          },
          "offsetMs": {
            "type": "integer",
            "minimum": 0
          },
          "afterStep": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Index of the last step that had happened when this was taken. Null on the opening shot, when nothing had happened yet."
          }
        }
      }
    },
    "files": {
      "type": "array",
      "description": "Every file in the archive except report.json and manifest.json: see filesNote.",
      "items": {
        "type": "object",
        "required": [
          "name",
          "mime",
          "bytes",
          "sha256",
          "role"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "mime": {
            "type": "string"
          },
          "bytes": {
            "type": "integer",
            "minimum": 0
          },
          "sha256": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$"
          },
          "role": {
            "type": "string",
            "enum": [
              "report-text",
              "report-markdown",
              "screenshot",
              "screenshot-primary",
              "log-console",
              "log-network",
              "log-actions",
              "har",
              "dom",
              "element",
              "form-state",
              "environment",
              "other",
              "schema"
            ]
          },
          "activeContent": {
            "type": "object",
            "description": "Present only where a file contains markup that would otherwise execute. Every number here is derived by scanning the serialized file rather than by counting during the pass that neutralised it, so it cannot describe work that did not happen, which is how `frames` was once correct about the pass and wrong about the file, the pass being unable to see <noscript> children. Grep the file for `<script` and `<iframe` and the numbers will agree.",
            "required": [
              "neutralised",
              "scriptTags",
              "frames",
              "framesNeutralised",
              "contentSecurityPolicy"
            ],
            "properties": {
              "neutralised": {
                "type": "boolean",
                "description": "Verified against the file: no frame retains a src or srcdoc, and no script retains a src. Inline script bodies are replaced during capture and their absence is not re-derivable by pattern, so that part remains a property of the pass rather than of the bytes."
              },
              "scriptTags": {
                "type": "integer",
                "minimum": 0,
                "description": "Every script element in the file. All of them carry type=\"text/plain\" and none retains a src."
              },
              "frames": {
                "type": "integer",
                "minimum": 0,
                "description": "Every frame element in the file, whether or not it needed changing. This is the number a reader grepping for `<iframe` will get."
              },
              "framesNeutralised": {
                "type": "integer",
                "minimum": 0,
                "description": "How many of `frames` carry a data-bugpacker-src or data-bugpacker-srcdoc, having had a source removed. The remainder had none to begin with."
              },
              "contentSecurityPolicy": {
                "type": "string"
              }
            }
          },
          "derived": {
            "type": "boolean",
            "description": "Rendered from data held elsewhere in this package. Safe to drop rather than attach."
          },
          "serveUnsafe": {
            "type": "boolean",
            "description": "Contains markup captured from a third-party page. Attach it; do not render it inside a host application's origin."
          },
          "lossy": {
            "type": "boolean",
            "description": "Present and true only on a screenshot that was re-encoded as JPEG because the lossless encode exceeded a size budget and the lossy one was smaller. Absent means pixel-exact. Masking is applied before encoding, so nothing hidden can be recovered from either form."
          }
        }
      }
    },
    "filesNote": {
      "type": "string"
    },
    "archive": {
      "type": "object",
      "description": "Validate before extracting. The two fields have deliberately different scopes; see filesNote.",
      "required": [
        "fileCount",
        "totalUncompressedBytes"
      ],
      "properties": {
        "fileCount": {
          "type": "integer",
          "minimum": 1,
          "description": "Every file in the archive, including report.json and manifest.json, which are absent from files[] because neither can contain its own digest."
        },
        "totalUncompressedBytes": {
          "type": "integer",
          "minimum": 0,
          "description": "The sum of files[].bytes, which excludes report.json and manifest.json. It cannot include them: report.json cannot state a total containing its own size, and manifest.json's size depends on report.json having been written first. Expect the extracted archive to be larger than this by the size of those two files."
        }
      }
    },
    "reportId": {
      "type": "string",
      "format": "uuid",
      "description": "Unique to this capture. Distinct from fingerprint, which identifies the defect: a re-capture after a fix attempt is a new reportId and the same fingerprint."
    },
    "reported": {
      "type": "object",
      "description": "What the person who captured this asserts. Unverified by definition. Rendering these with the same weight as measured fields is how a claim becomes the canonical name of an issue, consider showing title as a quotation rather than as the issue summary.",
      "required": [
        "title",
        "severity",
        "expected",
        "actual"
      ],
      "properties": {
        "title": {
          "type": "string"
        },
        "severity": {
          "type": "string",
          "enum": [
            "blocker",
            "major",
            "minor",
            "trivial"
          ],
          "description": "Always lower case here, and reporter-assigned against no rubric. The human renderings capitalise it for display."
        },
        "expected": {
          "type": "string"
        },
        "actual": {
          "type": "string"
        },
        "notes": {
          "type": "string"
        }
      }
    },
    "reporting": {
      "type": "object",
      "description": "Routing preset by the user in settings, so an integration can file without asking a human. Both fields are null unless set, an empty string never appears, so null is the only \"not provided\". This is the one part of a package that deliberately adds identifying data rather than removing it, and it is never scrubbed. It is also deliberately absent from BUG-REPORT.md and BUG-REPRO-STEPS.txt: those are pasted into tickets by hand, and should not carry an address the person pasting has forgotten is in there.",
      "required": [
        "reporter",
        "routingKey"
      ],
      "properties": {
        "reporter": {
          "type": [
            "string",
            "null"
          ],
          "maxLength": 120,
          "description": "Who filed this. Free text: a name, an email or a username, whichever the receiving system matches on. Treat as untrusted user input."
        },
        "routingKey": {
          "type": [
            "string",
            "null"
          ],
          "maxLength": 120,
          "description": "Where the issue belongs: a Jira project key, a Linear team, a GitHub repo. Free text on purpose, because every tracker models this differently. Treat as untrusted user input."
        }
      }
    },
    "findings": {
      "type": "array",
      "description": "The capture's own observations, including any that contradict `reported`. Route on `class`: contradicts-report means the evidence in this package argues against the report it is attached to, and is the signal to triage rather than file. Route on `audience` to decide where a finding renders, `reporter` findings were shown to the person capturing before they exported, `developer` findings travel in the package only. Absence of a finding is not evidence of absence of the condition; see the per-code notes for what each one is able to detect.",
      "items": {
        "type": "object",
        "required": [
          "code",
          "class",
          "audience",
          "confidence",
          "shownToReporter",
          "summary",
          "detail",
          "evidence"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "value-flags-would-justify-rejection",
              "duplicate-element-ids",
              "no-request-observed",
              "first-party-undetermined",
              "uncaught-errors-observed",
              "console-coverage-partial",
              "capture-scope-limited",
              "scroll-drift"
            ],
            "description": "value-flags-would-justify-rejection: a field the site rejected held a value carrying flags that make rejecting it correct. Fires only on reservedRange, invalidAreaCode and invalidExchange, deliberately narrow, because a false accusation of user error is worse than none. duplicate-element-ids: an id in a captured form appears more than once in the document. Where every copy is present in formState and all of them are flagged holding a value of the same shape, the detail says so, nothing in that capture indicates the duplicate misdirected the rejection, which is worth knowing beside a report about a field being wrongly rejected. Where only some copies are flagged, or the flagged copies hold different shapes, it says that instead, which is the stronger lead. Neither claim goes further than the evidence: which copy received the keystrokes is not observable from a snapshot taken afterwards. no-request-observed: a form was submitted and no request reached the page’s own site afterwards. Class observation, not defect-observed: the same evidence fits client-side validation refusing to send and a submission vanishing, and the capture cannot tell which. A request that reached the server and failed does not trigger it; that is a different condition with a different owner and is not currently detected. Medium confidence by construction, because native non-XHR form submission is not observed. first-party-undetermined: a form was submitted but the page’s host sits under a suffix the recorder cannot classify without the public suffix list, which it does not ship, so no conclusion about the submit is offered. Emitted only when that uncertainty actually suppressed a conclusion, an unrecognised suffix only ever widens the first-party test, so a no-request-observed that fires anyway is sound and is reported normally. Structurally rare: it is unreachable on any host under a gTLD, so a zero rate across a corpus of .com sites is expected and is not evidence the detection is broken. uncaught-errors-observed: JavaScript threw, rejected or logged an error during the recording. Says nothing about whether any of them is the reported bug. console-coverage-partial: console capture began when recording did, so anything logged during page load is absent. Suppressed when the recording started with a reload. capture-scope-limited: the recording left its original origin, the host permission for that origin was not held, and no console, network or action event carries a timestamp after the crossing. Derived from the events rather than from the permission: Chrome's one-tab grant covers same-origin navigation and reload, so those are not scope boundaries, and whether the recorders survived a crossing is observable after the fact. Medium confidence because a page that was genuinely quiet after the navigation is indistinguishable from one the recorders never reached. The permission situation itself is stated in manifest.json capture.scope and is not a finding: it is a fact about what was granted, not a claim about what was observed, and asserting the consequence without checking it discredits data that is sound. scroll-drift: the page scrolled between the marked shot and the end shot, so the two cover the same screen rectangle and not the same content. Requires both of those shots to exist; it says nothing about scrolling that happened at any other point, and does not fire when the end shot was dropped as a duplicate."
          },
          "class": {
            "type": "string",
            "enum": [
              "contradicts-report",
              "defect-observed",
              "observation",
              "coverage-limit"
            ],
            "description": "contradicts-report: the evidence argues against the report. defect-observed: the capture believes something is wrong. observation: the capture saw something worth stating and is not claiming it is wrong, route these for reading, not for escalation. coverage-limit: a statement about what this capture could and could not see. Escalating on defect-observed is safe; escalating on observation is not, because a finding can document the site behaving correctly."
          },
          "audience": {
            "type": "string",
            "enum": [
              "reporter",
              "developer"
            ],
            "description": "Who the finding is for. `reporter` findings are rendered on the review page above the export control, before the package is produced; `developer` findings are never shown at capture time and exist for whoever picks the ticket up. An integration can use this to decide whether a finding belongs in the issue body or in a comment back to the person who filed it."
          },
          "shownToReporter": {
            "type": "boolean",
            "description": "Whether this was displayed to the person capturing before they exported. True for every `reporter` finding. It distinguishes \"the reporter never saw this\" from \"the reporter saw this and filed anyway\", which is not recoverable from the text of either. It is a statement about what was rendered, not a claim that anybody read it."
          },
          "confidence": {
            "type": "string",
            "enum": [
              "high",
              "medium"
            ]
          },
          "summary": {
            "type": "string"
          },
          "detail": {
            "type": "string"
          },
          "evidence": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Where in the package the supporting data sits."
          }
        }
      }
    }
  }
}
