Metadata-Version: 2.4
Name: browniie
Version: 27.0.0
Summary: SWARM: scan a codebase's API dependencies, watch the providers, analyze impact, report it, and mend it with draft PRs — deterministic, no LLM, nothing leaves your machine.
License: Proprietary
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: pyyaml>=6.0
Requires-Dist: certifi>=2024.0
Requires-Dist: tree-sitter>=0.25
Requires-Dist: tree-sitter-javascript>=0.25
Requires-Dist: tree-sitter-typescript>=0.23
Requires-Dist: tree-sitter-ruby>=0.23
Requires-Dist: tree-sitter-go>=0.23
Requires-Dist: tree-sitter-java>=0.23

# Browniie

Your code depends on other people's APIs — Stripe, OpenAI, Twilio,
two hundred others — and those APIs change under you. Browniie maps
exactly where your code touches which provider (file and line),
watches those providers' published specs, SDKs and changelogs daily,
and tells you when a change actually affects one of your lines — with
a computed fix where one can be computed, and honest, sourced guidance
where it cannot.

Scanning, watching, analysis, reporting, and every deterministic fix
are fully local: no model calls, no telemetry, your code never leaves
your machine. One narrow exception exists and it is opt-in per repo:
an AI repair fallback that transmits only the affected function, is
gated on measured evidence, and can never auto-apply anything.

## Choose a tier (one config value)

Create `.browniie.yaml` at your repo root — or don't; the default is
the safest tier.

| tier | what it does | choose it when |
|---|---|---|
| `report` (default) | Notifies — a GitHub issue, Slack, or email when a provider change affects your code. **Never edits anything.** | You want to hear about breaks. Start here. |
| `suggest` | Additionally opens a **draft** pull request with the fix proposal. Never merges. | You want fixes proposed, humans deciding. |
| `auto` | Applies **deterministic fixes only** to a branch after every gate passes, with a stated revert path. | You trust replay-validated mechanical fixes on a branch. |

Two things about `auto`, because they are the point: it requires a
written acknowledgment sentence in your config (an informed opt-in,
not a flag), and **LLM-generated fixes are never eligible for it, at
any setting** — that is enforced structurally (there is no key or
code path that permits it), because per-fix generation measured
20–45% green-but-wrong and mechanical fixes measured zero.

## Install

Browniie is not on PyPI yet; install from the repository or a built
wheel (both verified from clean environments):

```
pipx install git+ssh://git@github.com/shubangowda/browniie.git
# or, from a checkout:  python -m build --wheel && pipx install dist/browniie-*.whl
```

Python 3.11+. That's it for scanning — no accounts, no tokens, no
config files. Or paste `docs/github-action-template.yml` into
`.github/workflows/` to adopt Browniie without installing anything.

## First scan (the whole point, in one command)

```
browniie scan /path/to/your/repo
```

At the terminal you get the dependency map itself — one line per
provider your code touches, with its confidence tier, call-site
count, and the files involved:

```
Browniie scan — your-repo
============================================================
  stripe         high confidence, 12 call site(s) — billing.py, hooks.py
  twilio         high confidence,  3 call site(s) — notify.js
------------------------------------------------------------
214 file(s) analyzed; full detail: browniie scan . -o report.json
```

Piped or written with `-o`, the output is the full JSON report
(stable, versioned contract): every provider with its evidence
(manifest pins, imports, call sites, raw HTTP calls, config
references), each row carrying a file and line you can open.
Five languages are analyzed: Python, JavaScript/TypeScript, Ruby, Go,
and Java. `.gitignore` is respected — vendored and generated code is
skipped, and skipped files are counted rather than hidden.

**Read the coverage statement first.** Every report ends with
`coverage`: how many recognized source files were analyzed and — the
half most tools hide — how many were NOT (Vue, Kotlin, PHP, Rust...),
per language, with an `unanalyzed_share`. If 40% of your repo is in a
language Browniie doesn't analyze, the report says so prominently
instead of letting an empty result read as a clean bill of health.
Absence of findings in unanalyzed code is not evidence of absence.

**What the evidence tiers mean.** Every claim is tiered by what it
actually proves: a *manifest* pin proves you declare the dependency;
an *import* proves you load it; a *call site* proves code invokes the
SDK; *raw_http* proves a URL is called (with the wrapper chain in
`via` when the call goes through your own helpers); *config* proves
only that a URL is known to your codebase. Things Browniie cannot
resolve become explicit `unknowns` — never silent, never guessed.

## Check your environment

```
browniie doctor
```

Prints what will and won't work here: Python version, container
runtime (needed only for Mend's verified bumps), config presence,
token validity. Most first-run failures are environmental; run this
before anything else when something seems off.

## Enabling the rest of the pipeline

- **Watch** (`browniie watch --once`): fetches the registry's 227
  providers' specs/changelogs into an append-only archive. Works
  immediately; in production a daily CI job runs it (see
  `.github/workflows/watch.yml`).
- **Analyze** (`browniie analyze <scan.json> <events.jsonl>`): joins
  your scan with watch events into an Impact Report — "this breaking
  change touches your `invoices.py:9`" — with honest confidence tiers.
- **Report** (`browniie report ...`): delivers the Impact Report as an
  idempotent PR comment and/or Slack digest. Needs a config file
  (`browniie.yaml` or `report_config.yaml` in your working directory)
  with a `github_token`; errors name the missing key, never a stack
  trace.
- **Mend** (`browniie mend plan|render|open-pr ...`): turns an Impact
  Report into a DRAFT pull request — version bumps only where an exact
  pin, strong evidence, a confirmed breaking event, and a
  container-verified test run all line up; review annotations
  otherwise; nothing at all where attribution isn't airtight. Mend is
  opt-in per repository and its token needs Contents: read + Pull
  requests: write on the target repo only.

## SDK surface diffing (v13) — line-level impact without a spec

Most providers publish no machine-readable spec — but nearly all
publish versioned SDKs, which are public source code in the five
languages Browniie already parses. Browniie diffs each SDK's PUBLIC
SURFACE between releases (methods removed, parameters now required,
types changed) and treats the result as a fourth watched artifact
class alongside specs and changelogs.

Three claims, kept strictly separate: a surface change is a FACT read
from source; "therefore the API changed" is an INFERENCE — SDK-derived
findings carry `source: sdk` as their own tier, never merged with
spec findings, and where the two disagree BOTH are shown with sources
named; "therefore your line 41 breaks" is a legitimate JOIN — you call
the SDK, so the surface change affects your call site regardless of
the wire, matched by call chain with no endpoint mapping involved.

The technique was MEASURED before it was trusted (SDK_ACCURACY.md):
for three providers publishing both a spec and an SDK, SDK diffing was
compared to spec diffing over real history — zero false signals in
every hand-verified sample, and it independently found stripe's basil
headline changes plus real product removals our spec watching was
structurally blind to. Its own blind spots, stated: wire-level VALUE
changes (enum values, status codes) have no SDK surface; an SDK can
lag or lead its API; and providers with neither spec nor official SDK
remain notice-only. Mend never bumps on an SDK-surface breaking
change — the newer SDK changes the very symbol your line calls, so a
bump would import the breakage; those rows annotate instead.

Provider coverage after v13: **36 spec-bearing / 115 SDK-watched /
the rest changelog-only** (of 227). Packages are never executed —
archives are extracted with traversal guards and parsed as text; only
each provider's OFFICIAL package is watched (community SDKs are
excluded by audit).

## Notifications — how you actually find out

Four channels, all owning the same silence law: **a day with nothing
relevant produces no message at all.** A tool that pings daily gets
muted; the credibility of the messages that do arrive depends on the
silence between them.

- **GitHub issue** (the report-tier workhorse): one issue, updated in
  place on later findings — never an issue-per-day pile.
- **PR comment**: for repos with an open PR, idempotently updated.
- **Slack**: a capped digest via your webhook.
- **Email**: plain SMTP from config you supply (`email:` in
  `report_config.yaml` — host, port, from_addr, to_addrs; STARTTLS by
  default). Browniie ships no mail relay and mails nobody you didn't
  name.

## What the coverage statement means

Every scan report says what was NOT read: per-language unanalyzed
file counts and their share. A monolith in an unsupported language
never produces a confident-looking empty report — absence of findings
in unanalyzed code is blindness, not cleanliness, and Browniie says
so on every report.

## The numbers behind the fixes (and their caveats)

Measured on a 320-case benchmark (five languages, five change shapes,
26 live third-party repos at pinned commits — three of them
deliberately stale or archived, code written between 2016 and 2019 —
every case proven broken-then-fixable in a network-less container,
the whole corpus measured by ONE authoritative CI ledger that
reconciles exactly with the local one, run 32939615425):

- **Wrong fixes shipped at auto tier: 0**, across every mechanism,
  every outcome verified with no sampling.
- **Deterministic fixes: 183 of 320 (57.2%)** — 71 by shape rules,
  42 by admitted recipes, 70 by admitted **migration maps** (seven
  of those on LIVE third-party repos, five where the real package
  declares nothing at all — the pure-move law: a sole target move
  touches no argument byte, so it cannot lose a value). These are
  the only fixes tier-3 auto may ever apply.
- **Overall: 90.0% of breaks end in a useful diff** — a
  deterministic fix (57.2%), a partial with marked holes (14.1%), a
  context-derived fix (3.1%), or a clearly-labeled model draft
  (15.6%); 10.0% end as annotations, each carrying its named
  reason, and 4.4% of the corpus is the honestly-named irreducible
  remainder: products retired outright (Twilio Fax, Microvisor,
  Plaid CRA...), where the only correct output is the clear notice.
  Under the strictest hand-graded reading (every draft verified, no
  sampling; one wrong and four misleading partials subtracted) the
  number is 88.4% — both are published, never blended. Model drafts
  in the shipping configuration measured 45/50 correct-or-holed
  (90.0%), and the two historic wrong families now end CORRECT:
  the second oracle rejects them against the artifact's own
  declarations and the repair loop fixes them from the rejection
  text (PRECISION.md, v27).
- **THE TWO BARS (v25), explained for a beginner.** Auto-apply
  commits with nobody between the fix and your repository, so its
  bar is *provably correct* — deterministic mechanisms only, and a
  model-drafted edit is refused even if it somehow carries a
  deterministic label (two independent barriers). Suggest opens a
  DRAFT pull request that you read before merging, so its bar is
  different on purpose: *is this more useful to you than a note
  saying the API changed?* A 75%-correct diff with its uncertain
  parts marked beats that note, because **you are the verification
  layer** — you can accept, edit, or close it, all cheaper than
  starting from prose. So at suggest tier the model may attempt any
  shape the deterministic engine refused — the residue only; a row
  a mechanism serves never reaches a model — under every structural
  guard (credential refusal, blast radius, traced locations only,
  tests/CI/manifests untouchable, detectors, value preservation,
  the container jail).
- **THE SECOND ORACLE (v26), and what it means for trust.** A
  passing test suite is a weak specification — the only wrong
  drafts ever measured were "green" under suites that specified
  almost nothing (a load-only suite cannot see a string passed
  where a date is declared). So every candidate fix is now judged
  twice: by your repo's own tests in the jail, AND by your repo's
  own type checker (mypy, tsc, sorbet, go build, javac) when you
  have one configured — baseline-diffed, so your pre-existing type
  errors are never blamed on the fix. No checker? The archived
  artifact's own declarations stand in: a literal passed where the
  provider declares a different type, or a wrapped response whose
  body is never read through the wrapper, is rejected before any
  test runs. Every PR body states WHICH oracles verified the fix —
  and a repo that adopts a type checker gives every future fix a
  second, independent verification for free.
- **What a model-drafted PR means for you**: the body says a model
  wrote it, names the model, states that a passing suite is NOT
  proof of correctness, quotes the shape's measured wrong rate when
  it is off the allowlist, and carries a five-point checklist (the
  successor, your values, THE READ SIDE, any value not from your
  code, whether the change is really a human's decision). It can
  never be auto-applied, at any setting.
- **The suggest-lane correctness rate, measured twice and stated
  plainly: pooled across both full residual-queue runs, 24 of 27
  jail-green drafts hand-verified correct (88.9%), and 34 of 37
  drafted outcomes correct-or-appropriately-holed (91.9%)** — every
  diff read against the provider's own migration document
  (PRECISION.md). Every wrong was ONE family, replicated across
  runs (a legacy->OpenAPI client migration judged by a load-only
  suite: string dates where the artifact declares date objects; an
  unread `.data` on a response wrapper) — exactly what the
  checklist's items 2 and 3 point at. Each full-queue run cost
  about $2.30, because the deterministic engine had already served
  everything else for $0 — that is the unit economics: the model is
  paid only for what nothing cheaper can do.
- **Migration maps are the newest mechanism, and the growth engine.**
  When a provider replaces a product wholesale (Anthropic's
  completions -> messages, Stripe's basil invoice preview), every
  symbol-level rule rightly refuses — but the mapping is published
  in the provider's own migration guide, which Browniie's archive
  already holds. A map is ONE authored artifact per provider version
  migration: ordered entries, each expressed over the same closed
  edit vocabulary as recipes, and **every entry cites the provider
  document that determines it — one uncited entry makes the whole
  map inadmissible**. What the guide does not determine becomes a
  loud hole, never an invention. A map is admitted only after
  replaying over every recorded observation at zero wrong, and one
  admitted map serves every user of that migration forever.
  How one gets authored: a model reads the archived guide plus both
  real surfaces and proposes the map as data; the validator kills
  structural violations; replay is the judge; a human reads the
  evidence and admits. **Eleven are admitted today**, and v25 added
  three kinds: MINED maps (the mapping is already written down in a
  provider reference or public catalog — aws-sdk-js-codemod's
  TRANSFORMATIONS.md is AWS-authored and citable — so it is
  hand-transcribed and replay-judged; no third-party codemod code
  is ever copied), CROSS-STATEMENT maps (a REBIND replaces a
  client's construction with the guide's successor, a FOLD deletes
  a binding and folds its values into the one call that used it —
  both under an association law that demands resolution-proof the
  statements belong together, and an all-or-nothing law so half a
  migration can never apply), and PURE-MOVE serving of undeclared
  surfaces (a sole target move touches no argument byte, so it may
  fire where metaprogramming hides the signature — stripe-python's
  live Invoice.upcoming).
- **Partial fixes: 33 (16.0%), counted separately on purpose** —
  nearly-complete PRs with a deliberately undefined
  `__BROWNIIE_HOLE__` blank that parses but cannot run, so a
  half-finished migration can never merge quietly. This now includes
  the dead-argument form: when a provider removed a parameter and
  its guide names a successor beside it, the dead argument is KEPT
  (loud, value preserved) and the successor arrives as a hole.
- **Context-derived fixes: 10 (4.9%, own category, not
  auto-eligible)** — the documented candidate your own code names
  exactly once; two matches refuse into a hole, and no ranking
  exists in any code path.
- **The archive census (`python run_census.py`)** ranks what to make
  deterministic next; with maps, its findings become migrations to
  author, and that is the standing growth loop: census -> map ->
  admitted -> every user of that migration served.
- **The honest caveat**: the deterministic share is the hard number,
  and it rises only through rules, recipes, maps, oracle coverage
  and the census — never by loosening a gate. Per-fix AI generation
  measured 20–45% green-but-wrong at baseline (298 hand-verified
  fixes), which is exactly why it is fenced the way it is — as the
  bar for UNATTENDED application, while the suggest lane's own
  measured rate is reported beside every draft. All 11 admitted
  recipes and all 11 admitted maps replay at zero wrong.

## What Browniie does NOT do

- It never edits code it cannot trace to a Watch event AND a Scan
  record, never rewrites call sites, and only ever opens DRAFT PRs —
  no merge or auto-approve capability exists in the codebase at all.
- It cannot tell you a version FIXES a breaking change — it verifies
  your tests pass with the new version (in a network-less container)
  and says exactly that.
- Providers with neither a spec nor an official SDK can never produce
  line-level impact rows — you'll get provider-level notices. After
  v13 that residue is ~76 of 227 providers (the rest are spec-bearing
  or SDK-watched).
- Recall is imperfect by design: unregistered providers, unanalyzed
  languages, and unresolvable dynamic code are SURFACED (unknowns,
  coverage, aggregated records), not silently skipped — but they are
  not detected.

## Privacy & security (the section to show your security team)

Every claim here is enforced by tests in this repository, not just
intended. **The most important paragraph in this document is the
boundary between what is always local and what transmits code:**

- **Scan, Watch, Analyze, Report are fully local and deterministic,
  forever.** No LLM, ever. The only outbound traffic is Watch
  fetching PUBLIC provider specs/SDKs, registry-release-date lookups,
  and the GitHub/Slack posts you configure. Your source never leaves
  the machine in any of these.
- **Mend v1 (version bumps) is local and deterministic.** No LLM, no
  code transmitted.
- **Mend v2 (LLM-generated code fixes) TRANSMITS a minimal slice of
  your source to a named model provider — and ONLY when you have
  explicitly enabled it** with a two-factor opt-in that requires
  writing an acknowledgment sentence naming the provider into your
  config. It is OFF by default; `browniie doctor` tells you its exact
  status for any repo. When on, what leaves the machine is the single
  affected function/class plus the change facts — never the whole
  file, never the repo — and the payload is refused outright (not
  silently redacted) if it contains credential-shaped content. Every
  request and response is recorded to a gitignored audit trail, and
  the PR body names the provider and model that wrote the diff — and
  the exact eligibility rule that permitted generating it. **A
  passing test suite does not prove a generated fix is correct** —
  Mend v2 verifies syntax adapts to the change, not that behavior is
  preserved. This is now MEASURED at scale, not hypothetical
  (BENCHMARK.md, v16): a 47-case benchmark across five languages and
  13 providers, run factorially across two current-generation models,
  with **every one of 298 suite-passing fixes hand-verified against
  the provider's real migration documentation**. Under v15 conditions
  **20.0% and 16.7% of suite-passing fixes were semantically wrong**.
  Three interventions — deterministic structural detectors, archived
  migration material added to the payload, and a prompt that makes
  declining a valued outcome — brought that to **0.0% and 5.3%**, at
  the cost of roughly half the fix rate. The residue is stubborn:
  migrations needing information the code does not contain stay
  wrong even with documentation supplied.
### How a fix actually reaches your code (v20)

Browniie tries hardest to never involve a model, and when it must, it
treats the model's output as a *proposal* that has to earn its way in.

**Deterministic first, always.** If the change shape has a rule, the fix
is computed from the archived SDK diff. No model is consulted, no code
is transmitted, nothing is billed — there is nothing to ask.

**Otherwise: a sandbox, then a gauntlet.** Your repository is copied to a
throwaway workspace. A model proposes the change *there* — your actual
codebase is read-only for the entire run — and the proposal must clear
ten gates before a single byte is written back:

| gate | what it checks |
|---|---|
| 0 | can this be computed deterministically? |
| 1 | is this shape eligible at all (measured evidence)? |
| 2 | credential refusal — never transmit secrets |
| 3 | generation, **into the workspace** |
| 4 | does the result parse? |
| 5 | blast radius; tests, CI and manifests are untouchable |
| 6 | structural detectors for known failure signatures |
| 7 | **semantic preservation — do all your values survive?** |
| 8 | **your test suite, inside the container jail** |
| 9 | promotion, byte-checked |

**Gate 7 is the one that saved a real migration.** On an actual Stripe
upgrade, a model proposed a fix that passed every other check — and
silently dropped two fields the caller was passing (`attributes` and
`inventory`, which Stripe's successor API has no home for). Gate 7
asserts that the set of *values* reaching the provider never shrinks.
Renames pass it by construction; silent data loss cannot.

**Nothing is promoted that a container did not run.** If no container
runtime is available, Browniie runs every static gate, tells you
execution was unavailable, and promotes nothing. It will never
substitute a weaker check and call the result verified.

### The deterministic fix engine (v17) — fixes that cost nothing

Before any model is considered, Browniie tries to **compute** the fix.
Five small primitives do the work, and a "rule" for a change shape is
just a short composition of them:

- **rename a keyword argument** — `max_tokens=64` becomes
  `max_completion_tokens=64`;
- **add a keyword argument** — but only with a value it can DERIVE
  (the version you were on defaulted it, or the migration names
  exactly one valid value, or it is a variable already in your code);
- **positional arguments to keywords** — using the archived
  signature's own parameter order;
- **change a call's target** — when the arguments are provably
  identical;
- **change an import's target** — checking symbol by symbol that the
  new module exports everything your file actually uses.

Why compose primitives instead of writing a fixer per migration? Because
the next twenty change shapes should cost **data, not code** — the
approach Moderne/OpenRewrite built a business on, and the reason a
whole SLF4J→Log4j migration there is a list of primitive invocations
rather than a bespoke program.

Why it is safe: a primitive fires only on a call site that **provably
resolves** to the changed symbol through the same evidence behind 239
hand-verified claims (never a name match, never a text match); edits
are **surgical byte ranges**, so your formatting and comments survive
untouched; two primitives disagreeing about the same bytes **refuses**
rather than merging; and running a rule twice is a no-op. Same input,
byte-identical output, every time — asserted by test.

What it costs: **nothing.** No API call, no payload, no acknowledgment,
because no code leaves your machine. Today 6.4% of the benchmark corpus
is fixed this way (it was 0%), and the limit is extractor reach rather
than correctness — see BENCHMARK.md.

**Adding a rule from a future benchmark finding** — the growth
mechanism, as a recipe: (1) find a shape the hand verification shows a
model getting right for mechanical reasons; (2) check the archived diff
actually states the transformation — if it needs information the
evidence lacks, stop, it is not derivable; (3) express it as a
composition of existing primitives in `fix_rules.py` (if you need a new
primitive, that is a signal to re-read DESIGN_fix_primitives.md, not to
add one casually); (4) replay it against every recorded observation of
that shape — 100% correct-or-refuse or it does not ship; (5) record the
classification and reasoning in BENCHMARK.md.

- **Generation is gated by MEASURED eligibility**
  (generation_eligibility.yaml — data, reviewed like the registry).
  Browniie will only attempt a generated fix for change shapes the
  benchmark cleared a stated evidence bar (>= 10 hand-verified
  observations, <= 10% wrong overall, zero wrong under the shipped
  configuration). Today that is required-parameter additions,
  parameter renames, and — ONLY when archived migration material is
  available — parameters becoming required. Conditional entries are
  enforced, not advisory: a shape allowed only with enrichment is
  never generated without it, and the PR body states which rule and
  which condition permitted the fix. Browniie will NOT attempt symbol
  removals (110 verified observations, 21.8% wrong, unmoved by every
  intervention) or parameter removals with no successor (28.6% wrong,
  every one a silent drop of the caller's argument). Shapes with no
  benchmark evidence are excluded by default: untested is not the
  same as safe. Everything excluded still gets Mend v1's
  deterministic annotations.
- **Artifacts carry metadata, never code contents**: paths, line
  numbers, call chains, provider names, event ids, version strings.
  Planted-credential tests assert a fake secret placed beside a
  matched URL and beside a manifest pin reaches no plan, PR body,
  title, branch name, or annotation. URLs are query-stripped
  everywhere (keys travel in query strings). The single exception is
  the PR's own diff — a diff contains the changed lines, and it lives
  only in your repository.
- **Untrusted code runs only in a container jail**: Mend's sandbox
  executes your test suite with --network=none, non-root, read-only
  rootfs, CPU/memory/PID limits, no inherited environment, and no
  host mounts beyond the throwaway repo copy. No container runtime ->
  no verification, stated — host execution does not exist as a
  fallback. Honest limit: containers are strong isolation, not a
  boundary against a determined attacker.
- **Token scopes, exactly**: Report needs Issues: write (PR comments).
  Mend's opener needs Contents: read + Pull requests: write, scoped
  to the target repo. Scan/Watch/Analyze need no token at all.
- **What Browniie reads**: the repo you point it at (respecting
  .gitignore), the registry, and its own archive. Sandbox test OUTPUT
  never enters any artifact (it can quote source).

---

*Everything below is the deep record: architecture, per-organ
contracts, the honesty rulebook, and the session-by-session
verification history. A first-time user doesn't need it; a reviewer
deciding whether to trust the tool does.*

> **Can this tool be trusted?** [PRECISION.md](PRECISION.md) is the
> running, auditable answer: 223 hand-verified claims across 18 real
> pinned codebases, cumulative, with the fabricated-attribution count
> (zero, ever) stated outright — plus disclosure of the defects our own
> gates caught. READINESS.md states plainly what Browniie is and is
> not ready for today.

## What v11 completes (SWARM is whole)

- **Mend is built** — the fifth organ, and the first code Browniie has
  ever had that modifies a repository. See the Mend section below; the
  two structural guarantees up front: *write capability came last* (the
  whole engine was built and tested against disk-rendered output before
  a single network write existed), and *Mend proposes, humans decide* —
  every output is a **draft** pull request. **No merge, push-to-default,
  force-push, or auto-approve capability exists anywhere in this
  codebase** — not behind a flag, not commented out. Absent capability
  cannot be accidentally enabled.
- **Go is language #4** (`languages/go_adapter.py`): go.mod/go.sum
  manifests, semantic-import-versioning (`stripe-go/v76` and `/v81`
  match one provider), package-scope-is-the-directory resolution maps,
  cross-package wrappers through the NEAREST go.mod (multi-module
  monorepos like GitLab work), net/http + resty + fasthttp + grequests
  + req with cited URL positions, struct-field base URLs, and honest
  unknowns for interface-typed clients and httptest URLs. Grafana's
  6,238 Go files went from a coverage warning (41% unanalyzed) to
  analyzed (0.7%).
- **Java is language #5** (`languages/java_adapter.py`): pom.xml +
  build.gradle(.kts) manifests -> `maven_coordinates`, imports +
  static imports + fully-qualified inline usage, builder-pattern
  clients, marker-gated HTTP fact sites (OkHttp `.url`, Retrofit
  `.baseUrl`, Spring RestTemplate/WebClient, Apache, JDK HttpClient,
  HttpURLConnection), a fluent chain counted as ONE call site, and
  @Autowired/@Inject fields as honest absences. Airbyte's Java
  connectors now show real call sites — and the coverage statement
  then surfaced the deeper truth (its CDK moved to Kotlin, 44.8%
  unanalyzed and stated).
- **227 providers** (127 -> 227; 37 with machine-readable specs),
  every URL batch-verified live before recording.
- **The privacy property is now TESTED, not intended** — see
  "Privacy" below.

## What v10 added (three languages, deeper chains)

- **Ruby is the third language** (`languages/ruby_adapter.py` on
  tree-sitter-ruby), at full contract parity — Gemfile/gemspec
  manifests, `require` + constant-resolved SDK calls
  (`Stripe::Charge.create`), Net::HTTP/HTTParty/Faraday (incl.
  connection objects)/RestClient/Excon/Typhoeus, `@ivar` fields, string
  interpolation, wrappers + multi-hop. Ruby's dynamic hazards
  (`method_missing`, `define_method`, monkey-patching) produce honest
  unknowns, never guesses. Adding language #4 is the same recipe (top of
  `languages/base.py`) — Ruby is its worked example.
- **Multi-hop wrapper attribution** (`DESIGN_multihop.md`): wrapper
  chains resolve up to `MAX_HOPS = 3` with the full chain recorded in
  `via` (`get_json -> api_request -> requests.get`); a chain one hop
  past the limit surfaces an honest depth-limit unknown carrying the
  partial chain; cycles terminate and never attribute; poisoning
  composes transitively, per-entry. The engine is a language-agnostic
  string-substitution pass in `base.py` — one rulebook for all three
  languages.
- **Template-aware config matching**: `{{config.domain}}.salesforce.com`
  attributes at config tier by reading the anchored literal suffix that
  *exists*; `api.{{provider}}.com` (no known literal suffix) never
  matches — an honest unknown.
- **127 providers at v10** (up from 62); v11 takes it to 227.
- **Memory**: the string-template index keeps unbounded-depth resolution
  tree-free; adapters release trees between languages so per-language
  tree sets never stack. Every one of the 18 tested repos fits a
  standard 16 GB CI runner (largest: n8n at 4.65 GB).

## What v9 surfaces that used to be silent (explained for a beginner)

Four kinds of API-calling code used to produce NOTHING — the worst kind
of miss, because a report with no rows reads as "all clear". Each is
now an honest statement, and it matters what each one does NOT claim:

1. **URL literals in data structures** (an endpoint sitting in a dict,
   array, constant, or return statement). Surfaced at the CONFIG tier
   with `"source": "code-literal"`. It claims the URL is *known to the
   codebase* — not that any call is made with it.
2. **Third-party HTTP wrapper libraries** (saleor routes everything
   through `requests_hardened.Manager`, whose `send_request` takes the
   URL SECOND). The registry's `http_wrappers` table records each
   library's documented signature — adding one is zero code, and every
   entry cites where its argument positions were verified, because a
   wrong position would extract the wrong string as the URL. A method
   whose position isn't confidently known emits an unknown NAMING the
   library — never a positional guess.
3. **Framework-mediated HTTP** (n8n nodes call `this.helpers.request*`;
   URLs are assembled at runtime). Surfaced as aggregated
   `framework_http` records — "API-calling code here, mediated by
   <framework>, not statically resolvable". It never names a provider,
   and the provider is NEVER inferred from the node's filename or
   directory (a path is a naming convention, not evidence). Matching is
   double-keyed: helper chain AND a framework marker import.
4. **Known wrappers passed as arguments**
   (`rate_limit_builder(...)(rl_requests.get)`). An unknown states the
   handoff and that the one-hop boundary was NOT extended.

**Volume law.** Every surfaced class aggregates: framework records are
one row per (framework, helper) with counts and capped file lists;
config hits aggregate per (provider, host); mined-literal harvest rows
aggregate per host. A 1,283-site framework or a 75-copy fixture URL is
counted honestly without drowning the report.

**Language coverage.** Every report now carries `coverage` — analyzed
and UNANALYZED source file counts per language and an
`unanalyzed_share`. A Ruby monolith states "78% of recognized source
was not analyzed" prominently (the CLI prints a NOTE at ≥15% and a
WARNING at ≥50%) instead of producing a confident-looking empty result.
Absence of findings in unanalyzed source is not evidence of absence.

Browniie watches the external web APIs a codebase depends on (Stripe,
Twilio, OpenAI, ...) and will eventually alert — and help fix — when those
APIs ship breaking changes. The pipeline is called **SWARM**: **S**can,
**W**atch, **A**nalyze, **R**eport, **M**end. This repo contains all five
layers:

* **Scan** reads a codebase and produces a JSON "API dependency manifest":
  which providers the code talks to, and exactly where (file + line).
* **Watch** monitors those providers from the other side: it fetches their
  machine-readable specs and changelogs on a schedule, archives every
  version forever, diffs consecutive versions, and emits a stream of
  classified change events (breaking / safe / info).
* **Analyze** joins the two and produces the Impact Report: "Stripe removed
  `GET /v1/invoices/upcoming` (Watch event) and your `invoices.py:13` calls
  it (Scan evidence)" — with an honest confidence tier on every claim.
* **Report** delivers that Impact Report where a human will actually see
  it: an idempotent PR comment with the file/line detail, and a short
  Slack digest that links to it.

Nothing in any layer uses an LLM. All four are deterministic: same inputs
→ same outputs, forever. Scan never executes the code it reads; Watch and
Analyze classify by rules, not judgment; Report's only prose is static
template wording written in report.py.

## Quick start

Setup (once): Python 3.11+, then:

```
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
```

Scan any repo — Python, JS/TS, or mixed; add `-o reports/name.json` to write
a file instead of printing:

```
.venv/bin/python run_scan.py /path/to/some/repo
```

Run one watch cycle over every registry provider (in production the
GitHub Actions workflow runs this daily — the process is one-shot by
design; there is no daemon):

```
.venv/bin/python run_watch.py            # or --provider stripe
```

Join a scan report with watch events into an Impact Report:

```
.venv/bin/python run_analyze.py reports/myrepo.json reports/watch/<run>.events.jsonl
```

Deliver it (after the one-time "Report setup" below):

```
.venv/bin/python run_report.py reports/analyze/myrepo.impact.json --pr owner/repo#123
```

Run the whole test suite (fixtures, unit tests, real-world validation for
all layers):

```
.venv/bin/python -m unittest discover -s . -p "test_*.py"
```

## Architecture: one orchestrator, one adapter per language

```
run_scan.py                  thin CLI
scanner.py                   orchestrator — file discovery, adapter routing,
                             config sniffing, confidence, report assembly
languages/
  base.py                    the LanguageAdapter contract + ALL shared
                             matching logic (domains, URL classification,
                             the multi-hop string engine)
  python_adapter.py          Python, on the stdlib `ast` module
  js_adapter.py              JavaScript/TypeScript, on tree-sitter
  ruby_adapter.py            Ruby, on tree-sitter (language #3; the
                             worked example of the adapter recipe)
  go_adapter.py              Go, on tree-sitter (language #4)
  java_adapter.py            Java, on tree-sitter (language #5)
mend.py                      Mend (SWARM step 5): tier gate, plan,
                             disk render, sandbox verify — the draft-PR
                             engine (see the Mend section)
registry.yaml                the known-API registry — data, never code
fixtures/                    fake repos with expected.json ground truth
realworld/manifest.yaml      pinned public repos + hand-verified expectations
```

The idea, for a beginner: the **orchestrator** knows nothing about any
programming language. It finds files, hands `.py` files to the Python
adapter and `.js/.ts/...` files to the JS adapter, and each adapter answers
the same four questions in its own language's way (what's declared? what's
imported? what's called? what's fetched over HTTP?). Everything that must
behave identically across languages — "is this URL a Stripe URL?" — lives in
ONE shared place (`languages/base.py`), so no language can drift.

Python parses with the standard library's `ast`; JS/TS parses with
tree-sitter (the industry-standard parsing library with grammars for ~40
languages — the road every future language walks). The adapter interface is
what makes that mixed-parser reality invisible.

**Adding a language never touches the orchestrator.** `languages/base.py`
contains the complete recipe (a future session could add Ruby from its
comments alone): write one adapter class, add per-language fields to
registry.yaml, register one line in `ADAPTERS`, add fixtures.

## The five layers

Each layer answers a different version of "does this code use provider X?",
in every language scanned:

**1. Manifest.** What's declared: `requirements.txt` / `pyproject.toml`
(Python), `package.json` dependencies + devDependencies (JS). Cheapest
signal — the developer wrote it down on purpose — but says nothing about
where, and can be stale.

**2. Imports.** What's wired in: `import stripe as payments`,
`import Stripe from 'stripe'`, `const twilio = require('twilio')`. This
layer also builds the per-file **alias map** (local name → real package)
that lets the next two layers see through renames instead of being fooled.

**3. Call sites.** The strongest evidence: the exact lines that invoke the
SDK — `payments.Charge.create(...)`, `new Stripe(key)`,
`client.chat.completions.create(...)`. Both languages track the one-level
client pattern (`client = OpenAI()` … `client.x.y(...)`) — these file+line
records are what Watch/Mend will point at.

**4. Raw HTTP.** Code that skips SDKs: `requests.post("https://api.stripe.com/...")`,
`fetch(...)`, axios instances with a `baseURL`, HTTP client objects
(`requests.Session()`, `aiohttp.ClientSession()`). URLs are resolved as far
as literals allow — through f-strings/template literals, `+` concatenation,
module-level and single-assignment local constants, parameter defaults,
same-file single-return helpers, and single-hop wrapper calls attributed
to the caller's line (the bounded v2.3 powers — see the contract
changelog below; cross-file wrappers and runtime config stay honest
unknowns).
Every resolved URL is then classified by one shared rulebook:
provider evidence, or `unrecognized_urls` (public host, not in the registry
— the signal for growing it), or dropped (internal hosts). Anything
unresolvable is recorded in `unknowns` — an honest "there's an HTTP call
here I can't attribute" is a feature, never a silent miss.

**5. Config sniffing.** Provider domains sitting in `.env`, `*.yaml`,
`*.json`, `*.toml`, `*.ini`, `*.cfg` — read as plain text, matched with the
same dot-anchored rule as Layer 4 (lookalike domains don't pass). Reports
carry only the trimmed URL token, never whole config lines, which can hold
secrets next to URLs.

### Confidence levels

| Level  | Meaning |
|--------|---------|
| `high`   | At least one call site or raw HTTP hit — the code demonstrably talks to the API. |
| `medium` | Imported or declared in a manifest, but never seen used. |
| `low`    | Only a domain in a config file — a clue, not proof. |

Only providers with at least one piece of evidence appear in a report.

## Output schema (contract) — v2.6

**This structure is a frozen contract.** Every future SWARM layer (Watch,
Analyze, Report, Mend) consumes exactly this shape.

**v2.4 → v2.6 changelog** (v2.5 was behavioral only; v2.6 is additive):
- **NEW top-level `coverage`** — `analyzed` (mirrors `languages`),
  `unanalyzed` (per-language file counts for recognized-but-unsupported
  source), `unanalyzed_share`.
- **NEW top-level `framework_http`** — aggregated framework-mediated
  HTTP records: `{framework, detail (helper), sites, file_count, files
  (capped 20), reason}`. Never carries a provider.
- **Config records may carry provenance/aggregation fields** —
  `source: "code-literal"` (mined from source data structures vs.
  sniffed from a config file), `sites` (occurrence count), `files`
  (capped list). Config-file hits aggregate per (provider, host).
- **`url_line`** on raw_http/unknown records — the line that SUPPLIED
  the URL when a carrier on a different line provided it (evidence
  stays anchored to the transport-call line; both halves of the
  historical near-miss ambiguity are now on the record).
- **`sites`** on mined-literal `unrecognized_urls` rows (per-host
  aggregation for the registry-growth harvest).
- Registry gains three data-only sections consumed by Scan:
  `http_wrappers`, `http_frameworks`, and per-provider
  `config_domains` (narrower domains for the config tier only).
- Report content object: `footer.marker` (additive) — the idempotency
  marker now hashes the FULL scanned-subject path (opaque), so monorepo
  subdirectory reports keep separate PR comments.

**v2.4 → v2.5** (behavioral only, like v2.3 — no new fields): the
precision-hardening session added OBJECT-METHOD WRAPPER attribution.
A module-level exposure of a repo-local class whose methods make direct
HTTP/SDK calls (`session = RateLimitedSession()` … `session.get(url)`,
or onyx's real shape — a class ALIAS whose attributes are verified
verbatim-forwarding re-bindings of `requests.get`) now attributes at the
caller's line, `via: "ClassName.method"`. The rules mirror Python's own
attribute binding: `def` methods resolve only through INSTANCES, plain-
function attributes only through ALIASES (each other quadrant would put
the URL in an argument slot Python never delivers it to — refused).
Boundary, unchanged in spirit: module-level exposures only; an instance
constructed inside a function or passed as a parameter NEVER resolves.
Methods are also excluded from bare-name wrapper lookup in both
languages (a method is not callable by bare name — a small pre-existing
misbinding vector, closed).

**v2.2 → v2.4 changelog** (additive — the wrapper-attribution session):
- **Wrapper attribution.** Any `call_sites` / `raw_http` / `unknowns`
  record MAY carry `via`: the repo-local wrapper hop it was attributed
  through (e.g. `"rl_requests.get"`). One hop, same repo only: a caller of
  a wrapper resolves, a caller of a caller does not. The hop is recorded
  so the attribution is auditable — a reader can open the wrapper and
  check it. Symmetric across Python and JS/TS.
- **Class/instance field base URLs.** `self.base_url` (Python) /
  `this.baseUrl` (JS) resolve when the field is a single string literal
  set at class level or in `__init__`/constructor. Reassigned-to-a-
  different-literal or runtime-sourced fields are poisoned (never guessed);
  subclass inheritance is out of scope for v1.
- **NEW top-level `proxies`.** A proxy library (litellm, langchain's
  `init_chat_model`) routes to a runtime-chosen provider. Each routing
  call emits a proxy record — `{proxy, file, line, detail,
  resolvable_provider (ALWAYS null), reason}` — naming the PROXY, never a
  provider. Kept strictly out of the `providers` map: it is a different
  KIND of fact (see "The proxy honesty design" below).
- **NEW top-level `model_literals`.** A string LITERAL in source matching
  a registry `model_prefixes` entry (`"claude-sonnet-4-…"`) yields a
  `{provider, file, line, detail}` record — a clearly-labeled lower tier
  (intent, not a proven call path). A model value from a variable, call,
  config, or `${hole}` template produces NOTHING, ever.

**v2.1 → v2.2 changelog** (additive):
- Discovery **respects `.gitignore`** by default — nested files, negation
  (`!pattern`), directory-only patterns (trailing `/`), anchored vs
  floating patterns, character classes, and `**` globs. The hardcoded
  skip floor (.git/, node_modules/, venvs, __pycache__) still applies
  even without any .gitignore. Opt out with `--no-gitignore`.
  Deliberately unsupported (documented, not pretended): backslash
  escapes, core.excludesFile / .git/info/exclude, and re-including files
  under an excluded parent directory (git's own documented behavior).
- NEW top-level `files_skipped`: gitignore-excluded file count — the
  exclusion is visible, never silent. (Why this exists: the first
  dogfood self-scan drew 99% of its evidence from gitignored data
  directories.)

**v2.2 → v2.3 changelog** (behavioral, no schema change): bounded
cross-function URL resolution — module-level constants resolve inside
function bodies, per-scope single-assignment locals resolve at their use
sites (any unresolvable or repeated assignment POISONS the name — the
scanner refuses rather than guesses), parameter defaults resolve, calls
to same-file single-return-literal helpers resolve, and a same-file
wrapper function whose HTTP call depends on its parameters attributes
the request to the CALLER's line when the caller's arguments supply a
real URL (`post_json(API_BASE + "/v1/refunds", ...)` is evidence at that
line). Symmetric in Python and JS/TS. Still honest unknowns: cross-FILE
wrappers, class fields, runtime config, and multi-hop chains.

**v2 → v2.1 changelog** (additive):
- Every `call_sites` record gains `"kind"`: `constructor` | `method` |
  `other` (definitions in `languages/base.py`). A heuristic priority hint —
  `method` approximates "may hit the network" — never proof.
- Manifest discovery is repo-wide: `requirements.txt` / `pyproject.toml` /
  `package.json` are found at any depth (same skip rules as source
  scanning), and each manifest record's `file` is its repo-relative path.

**v1 → v2 changelog** (deliberate, one-time, additive):
- NEW top-level `languages`: which languages were scanned, with file counts.
- NEW top-level `unrecognized_urls`: fully-resolved external URLs that match
  no registry provider (public hosts only). v1 dropped these; v2 surfaces
  them as the discovery engine for growing the registry.
- Ordering is now contractual: every list is sorted by (file, line). v1
  left ordering to AST-walk luck.
- Everything else is unchanged — v1 consumers keep working.

```json
{
  "repo": "path/that/was/scanned",
  "scanned_at": "2026-08-07T12:00:00+00:00",
  "files_scanned": 6,
  "languages": [ {"language": "javascript", "files": 2}, {"language": "python", "files": 3} ],
  "providers": {
    "stripe": {
      "display_name": "Stripe",
      "confidence": "high",
      "evidence": {
        "manifest":   [ {"file": "package.json", "detail": "stripe@^14.5.0 (dependencies)"} ],
        "imports":    [ {"file": "billing.py", "line": 2, "detail": "import stripe"} ],
        "call_sites": [ {"file": "billing.py", "line": 8, "detail": "stripe.Charge.create"} ],
        "raw_http":   [ {"file": "sync.js", "line": 12, "detail": "https://api.stripe.com/v1/charges"} ],
        "config":     [ {"file": ".env", "line": 2, "detail": "https://api.stripe.com"} ]
      }
    }
  },
  "unknowns": [ {"file": "sync.py", "line": 8, "library": "requests", "reason": "URL built from variable"} ],
  "unrecognized_urls": [ {"file": "push.js", "line": 4, "url": "https://api.segment.io/v1/track", "domain": "api.segment.io"} ],
  "errors": [ {"file": "legacy.py", "reason": "SyntaxError: ..."} ]
}
```

Field by field:

- `repo` — the path that was scanned, exactly as given on the command line.
- `scanned_at` — UTC timestamp of the scan, ISO 8601.
- `files_scanned` — count of distinct files the scan opened (sources + config + manifests).
- `languages` — one entry per language that had at least one source file: `language` (adapter name; `javascript` covers TS too) and `files` (source files opened, broken ones included).
- `providers` — one entry per detected provider, keyed by registry key; providers with zero evidence are omitted entirely.
  - `display_name` — human-readable provider name, from the registry.
  - `confidence` — `high` / `medium` / `low`, per the table above.
  - `evidence.manifest` — declared-dependency hits: `file` + `detail` (the requirement line, or `package@version (section)` for npm).
  - `evidence.imports` — import/require statements: `file`, `line`, `detail` (the statement as written).
  - `evidence.call_sites` — SDK invocations: `file`, `line`, `detail` (the dotted chain as written; JS constructors read `new Stripe`), `kind` (`constructor`/`method`/`other` — see base.py; heuristic, not proof of network traffic).
  - `evidence.raw_http` — direct HTTP calls to provider domains: `file`, `line`, `detail` (the URL; unresolved holes shown as `{}`).
  - `evidence.config` — provider domains in config files: `file`, `line`, `detail` (trimmed URL token only — never the whole line).
- `unknowns` — HTTP calls whose URL could not be resolved statically: `file`, `line`, `library`, `reason`. Honest blind spots, never dropped.
- `unrecognized_urls` — resolved URLs on public hosts that match no registry provider: `file`, `line`, `url`, `domain`. Internal hosts (localhost, IP literals, hosts without a public TLD) are excluded.
- `errors` — files the scan had to skip: `file`, `reason`. A broken file never kills a scan.
- Every evidence/unknowns/unrecognized/errors list above is sorted by
  (file, line) — contractual since v2. (`languages` sorts by name; it has
  no file/line.)

## Real-world validation

Fixtures are exams the scanner wrote for itself; `realworld/manifest.yaml`
holds exams it didn't write: five public repos (Stripe, OpenAI ×2, Plaid,
Slack official samples) pinned to exact commits, with expectations
established by reading the code by hand — every bound in the manifest cites
file/line proof. `test_scan_realworld.py` clones each pin shallowly into the
gitignored `realworld/clones/` and enforces the bounds; it skips cleanly
when offline.

Beyond the manifest, six large/diverse real codebases have been scanned
and HAND-verified at pinned commits (onyx, cal.com, airbyte, n8n,
saleor, stripe-payments-demo — per-repo detail in
`realworld/oss_findings.md`). The cumulative scoreboard is
[PRECISION.md](PRECISION.md): 83 verified claims, zero fabrications
ever. The registry stands at 227 providers across every major category — the backlog entries demanded by real
usage found in those scans — never guessed.

## Known limitations (v9 — post-surfacing taxonomy)

The complete taxonomy — 14 named classes with frequencies and a
SILENT-vs-surfaced verdict on each — lives in
`realworld/oss_findings.md`. The v8 silent quartet (framework-mediated
HTTP, URL-literals-in-data-structures, third-party wrapper libraries,
callables-passed-as-arguments) is now SURFACED and verified on the
repos that measured it. What remains:

- **Still silent, named (2):** callers beyond the one-hop wrapper
  boundary (by documented design — the wrapper's own line surfaces;
  multi-hop needs its own design session), and templated tenant hosts
  in config (`{{config.domain}}.salesforce.com` — one measured site;
  needs template-aware config matching).
- **Registry-data frontiers (not structural silences):** an
  UNREGISTERED wrapper library, framework, proxy, or provider is
  invisible until its data entry exists — extending any of them is
  zero code.
- **Perennials:** dynamic imports are invisible to static analysis;
  minified bundles excluded by design; private-host filtering is a
  simple heuristic, not the public-suffix list; model-literal and
  config line-grep matching have documented lookalike noise (a
  `gemini-`-prefixed non-model string, a commented-out example URL) —
  clearly labeled at their lower tiers, never a provider attribution
  above them; unsupported-language source is now REPORTED per repo in
  `coverage` rather than silently omitted (Ruby leads the measured
  queue at 11,214 Discourse files, Go second at 6,238 Grafana files).

## Adding a provider (zero code)

Open `registry.yaml`, add an entry, done — every layer in every language
keys off the registry:

```yaml
  mailgun:
    display_name: "Mailgun"
    pip_packages: ["mailgun"]              # as spelled in requirements.txt
    import_names: ["mailgun"]              # as spelled in `import X`
    npm_packages: ["mailgun.js"]           # as spelled in package.json
    js_import_names: ["mailgun.js"]        # as imported/required in JS
    api_domains: ["api.mailgun.net"]       # dot-anchored, subdomains included
    docs_url: "https://documentation.mailgun.com/"
```

## Adding a language (zero orchestrator changes)

The complete recipe lives at the top of `languages/base.py` — one adapter
class, new per-language registry fields, one line in `ADAPTERS`, fixtures.
The contract (record shapes, the shared URL classifier, what each method
returns) is documented there field by field.

---

# Watch (SWARM step 2)

## What a watch cycle does

`run_watch.py` runs one cycle: for every provider in the registry with a
`watch` block, it fetches each recorded source (politely: conditional
requests, honest User-Agent, per-fetch timeout), archives the result, and —
when a source's content actually changed — diffs the new version against
the previous one and emits classified change events. One provider's outage
never aborts the cycle; failures land in the run summary's `errors`.

## The archive — and why it's built the way it is

**The archive is the moat.** Every snapshot stored today is historical data
a competitor starting later can never recreate. That dictates the design:

* `archive/` is **append-only, forever**. No code path deletes or rewrites
  anything in it; there is deliberately no cleanup function and no
  retention policy. Corruption is handled by failing loudly — never by
  overwriting history. Losing archive data is the worst possible bug in
  this codebase, worse than a crash.
* `archive/` is **its own git repository**, separate from the code repo
  (which gitignores it). Watch initializes it on first run. Data and code
  have different lifecycles: the archive repo's history IS the historical
  record — `git log` inside it is the timeline of an API ecosystem — and
  git provides dedupe, integrity hashes, and time travel for free.
* Layout per source: `archive/<provider>/<source-slug>/current.<ext>` (the
  latest version) plus `meta.jsonl` — an append-only log with one JSON line
  per fetch, forever: `{fetched_at, url, content_sha256, http_status,
  etag, last_modified, changed, archive_commit}`; failure lines carry an
  additional `error` string, and html lines that fetched a body carry
  `text_sha256` (see the v1.1 changelog).
  Content changes are committed to the archive repo; unchanged polls cost
  one meta line, no commit.
* **Replication** (v1.1, merge-aware since v1.3): when
  `watch_config.yaml` enables `archive_replication`, every cycle ends by
  reconciling with the remote and pushing. If CI and a local cycle
  diverged, the cycle MERGES (never rebases — meta lines cite local
  commit hashes): fetch logs are unioned with the append-only prefix
  property verified per file (a violation aborts loudly — rewritten
  history is never auto-resolved), conflicting snapshots keep the newest
  fetch with the other side preserved in the merge's history, and
  anything else conflicting refuses with instructions. Only the remote *name* lives
  in config — the remote's URL is per-machine state (in CI it embeds an
  access token) and is configured once per archive clone with
  `git -C archive remote add origin <url>`. A failed push is a loud error
  in the run summary (`archive_push` field) and a nonzero exit from
  `run_watch.py`, but it **never blocks or rolls back local archiving**:
  local truth first, replication second. Once configured, replication is
  part of the archive law — never disable it to silence a failure.

## Where sources come from: the registry `watch` block

Each provider entry in `registry.yaml` carries a `watch` block:
`spec_sources` (machine-readable API specs — every URL verified live when
recorded) and `changelog_sources` (public changelog pages; `html`,
`markdown`, or `rss`). Providers with no public spec are recorded honestly
with `spec_sources: []` — never faked. Adding or removing a source is a
data change; no code changes, ever.

## The differ

For OpenAPI sources, `spec_diff.py` compares consecutive versions:

* Local `$ref`s are resolved on demand with cycle protection; external
  refs surface as `unresolved_ref` info events — never silently dropped.
* Every change is classified **breaking** (removals, new requirements,
  type changes, enum removals, auth changes), **safe** (additions, relaxed
  requirements, docs-only churn — coalesced to one event per operation),
  or **info** (anything the rules can't confidently classify — honest
  unknowns, never guesses). Three stream-level categories are emitted by
  Watch itself, outside the differ: `changelog_updated` (info),
  `events_suppressed` (info), and `component_changed` (rollups). The
  differ's full category list and boundary decisions
  are documented in `spec_diff.py`'s docstring.
* Changelog pages are **text-level only in v1**: change is detected by
  hash, every version is archived, and one `changelog_updated` info event
  carries the added-text excerpt (bounded). No semantic parsing of prose —
  that is future-layer work.

Validated against reality (`realworld/watch_manifest.yaml`): pinned
historical Stripe and Twilio spec versions months apart, with expected
changes hand-verified from the providers' own release notes before the
differ ever ran; a multi-megabyte Stripe diff completes in well under a
second.

## Watch contract v1 (frozen)

Each run writes `reports/watch/<run_id>.events.jsonl` (one event per line)
and `<run_id>.summary.json`. **This is the second frozen contract — Analyze
consumes it.**

Event fields:

- `event_id` — deterministic 16-hex id: sha256 over
  `provider|source_url|path|category|detail`. Same change re-derived from
  the same data → same id; replays dedupe for free. No random UUIDs.
- `provider` — registry key (`stripe`).
- `source_url` — the watched document the change came from.
- `detected_at` — ISO-8601 UTC timestamp of the run that saw it.
- `category` — taxonomy label (`endpoint_removed`, `enum_values_added`,
  `changelog_updated`, ...).
- `severity` — `breaking` | `safe` | `info`.
- `path` — JSON-pointer-style location inside the spec (RFC 6901 escaping:
  `/paths/~1v1~1charges/post/...`); `/` for whole-document events.
- `detail` — human-readable one-liner.
- `before` / `after` — OPTIONAL small values where sensible (e.g. old/new
  type). Absent otherwise.
- `spec_version_before` / `spec_version_after` — `{sha256, archive_commit}`
  of the two versions diffed, so the exact bytes behind any event are
  forever retrievable from the archive repo.

Run summary fields: `run_id`, `started_at`, `providers_checked`,
`sources_fetched`, `sources_changed`, `events_total`,
`events_by_severity` (`{breaking, safe, info}`), `errors`
(`[{provider, url, error}]`).

### Contract changelog — v1 → v1.1 (all additive)

Event stream:

- `component` — OPTIONAL on per-site events: JSON pointer of the shared
  definition (`/components/schemas/Charge`) the change originates in, when
  the differ descended through a `$ref` to find it. Absent when the change
  is inline.
- **`component_changed` rollup events** — one per component that
  originated changes in a diff: `path` = the component pointer,
  `severity` = max of the children's, `children` = sorted list of the
  per-site events' `event_id`s, `operations` = sorted operation pointers
  touched. Per-site events remain the canonical record; rollups are the
  aggregated view ("Charge changed, touching 50 operations") and are
  counted separately in the summary, never in `events_by_severity`.
- **`events_suppressed` events** — when `watch_config.yaml` lists
  suppression patterns, matching **info** events (and only info — the
  code refuses to collapse breaking/safe) are collapsed into one counted
  summary event per affected source. Nothing is silent: the count is in
  the detail, and every collapsed change is re-derivable from the archive
  via `spec_version_before`/`after`.
- html changelog sources: change detection and excerpts now use
  **extracted text** (stdlib html.parser; script/style/noscript stripped,
  whitespace collapsed), killing SPA build-id/nonce false positives. The
  RAW html is still what gets archived. meta.jsonl lines for html sources
  gain `text_sha256` — on lines that fetched a body (304 and error lines
  have no body to hash and omit it).

Run summary: gains `archive_push` (`null` when replication is not
configured, else `{remote, ok, error}`), `suppressed_events`,
`rollup_events`.

### Contract changelog — v1.1 → v1.2 (all additive; the CI data-loss fix)

Found in production: CI runs left snapshots but ZERO meta.jsonl fetch
logs on the remote (the runner is destroyed each run, and meta lines
relied on riding into the next content commit that never came). v1.2:

- Every cycle ends with one **fetch-log sweep commit** staging all
  meta.jsonl files; the run summary gains `fetch_log_commit` (null when
  the cycle recorded nothing new).
- **Change detection derives the previous version's hash from the
  archived bytes**, never from the meta log — an absent or lagging log
  can no longer fake a change (or hide provenance). The log records
  fetches; the file is the truth.
- Event provenance survives a log-less clone: when no meta line records
  the previous version's commit, it is recovered from `git log` on the
  source's path.
- `archive_commit` in meta lines is only trusted when the commit
  actually succeeded (no more stamping unrelated hashes on no-op
  commits).

## Known limitations (Watch v1.1)

- **Changelog prose is not semantically parsed** — hash-detect (on
  extracted text for html), archive, excerpt. Meaning extraction is
  future-layer work.
- **External `$ref`s are not resolved** (surfaced as `unresolved_ref`).
- **Polling, not push** — changes are seen at the next cycle, not the
  moment they ship.
- **Spec coverage is uneven across providers** — 37 of 227 have
  machine-readable specs today (8 seeded + harvest additions incl.
  dub, atlassian, paypal, asana, adyen, cloudflare, digitalocean,
  vercel, datadog); the rest are changelog-only or scan-only, each with its
  reason recorded honestly in the registry.
- **Provider release notes and spec files can disagree** — the Twilio and
  plaid validations both found documented changes that never landed in
  the pinned spec file. Watch reports what the watched document actually
  says, nothing more.

# Analyze (SWARM step 3)

Analyze answers the question the first two layers exist for: **does this
upstream change affect THIS codebase, and where?** It joins a Watch event
stream against a Scan report and writes the Impact Report. No LLM, no
guessing — the join is deterministic, and every claim carries the tier of
evidence behind it.

## The join, explained for a beginner

A Watch event says *where in the spec* something changed, as a JSON
pointer: `/paths/~1v1~1charges/post/parameters/currency`. RFC 6901
unescaping (`~1`→`/`, `~0`→`~`) recovers the endpoint (`/v1/charges`) and
the HTTP method (`post`). A Scan report says *where in the code* a
provider is used, as two kinds of evidence:

1. **Raw URLs** (`raw_http` evidence): the code literally contains
   `https://api.stripe.com/v1/invoices/upcoming`. Matching is segment-wise
   and template-aware (`{charge}` matches `ch_123`; a code f-string `{}`
   matches a template), with two overclaim brakes: segment counts must be
   EQUAL (no prefix conflation), and a **literal endpoint shadows a
   template match** — `/v1/invoices/upcoming` in code never satisfies
   events on `/v1/invoices/{invoice}`, because the table knows `upcoming`
   is its own endpoint (router semantics; found by the real-world exam).
2. **SDK call sites** (`call_sites` evidence, `kind == "method"` only):
   `stripe.checkout.Session.create` names no URL, so Analyze builds a
   **method↔endpoint table** from the provider's ARCHIVED spec and applies
   per-language naming-convention rules to propose candidate operations.
   Only candidates that EXIST in the table are ever claimed — a
   convention rule can propose nonsense, but nonsense isn't in the spec.
   Because removed endpoints exist only in PRE-change specs, the table is
   the union of the archive's current version and every
   `spec_version_before` the events cite (retrieved with `git show` from
   the archive repo — the archive is a time machine).

## Honest confidence tiers

| Tier | Evidence | Claim strength |
|---|---|---|
| `direct` | changed endpoint matches a literal URL in the code | this exact line calls the exact changed endpoint |
| `endpoint` | SDK call site maps to the endpoint via the table | strong, one deterministic inference deep |
| `provider` | high-confidence provider usage, no endpoint link | "you use Stripe; Stripe shipped a breaking change; we could not narrow it" — surfaced as a notice, NEVER attributed to a file/line |

Never emitted: guesses. Events for providers the codebase doesn't use are
counted (`unmatched_events`), not itemized. Info events (changelogs,
suppressed-churn summaries, ref problems) land in `not_assessable`,
honestly labeled. Safe changes with no endpoint link are counted
(`safe_unlinked`), not itemized — hundreds of harmless additions per
release window must not bury the breaking signal.

## Method↔endpoint mapping coverage (roadmap, not shame)

| Provider | Status |
|---|---|
| stripe | **First-class, validated**: python classic (`stripe.checkout.Session.create`) + node resource-walk (`stripe.paymentIntents.confirm`); 22 chains hand-verified against docs.stripe.com/api AND re-validated against the real archived spec |
| openai, twilio, github, sendgrid, plaid, discord | Generic resource-walk rule over their archived spec (existence-guarded); unvalidated — treat `endpoint`-tier hits as strong hints until a hand-verified chain set like Stripe's exists |
| slack | Falls to `provider` tier by construction: its URL style (`/chat.postMessage`) defeats the resource walk — honest empty mapping |
| anthropic, google-maps, shopify, hubspot, notion, aws | No archived machine-readable spec → no table → `provider` tier |

To add first-class mapping for a provider: read its SDK docs, hand-verify
15+ real chains against its API reference (the Stripe methodology in
test_analyze.py), add convention rules to `chain_candidates()` ONLY if the
generic walk fails, and encode the chains as test expectations. The table
itself never needs authoring — it derives from the archive.

## Impact Report — Analyze contract v1 (frozen)

`run_analyze.py` writes `reports/analyze/<repo>.impact.json`:

- `analyzed_at` — ISO-8601 UTC of the analysis run.
- `scan_report` — `{repo, scanned_at}` of the Scan input.
- `events` — `{refs, total}`: run ids / file names and event count.
- `impacts` — sorted by (provider, event_id), each:
  - `event_id`, `provider`, `severity`, `category`, `path`, `detail` —
    copied from the event; `endpoint` — the recovered endpoint (null when
    the event isn't under `/paths/`).
  - `affected` — sorted by (file, line): `{file, line, evidence_kind
    (raw_http|call_site), tier (direct|endpoint), detail}` — detail is the
    URL or SDK chain that produced the match.
  - `rollup_ref` — OPTIONAL: event_id of the `component_changed` rollup
    this event belongs to (walk it for the aggregated view).
- `provider_level_notices` — breaking events for used-but-unlinked
  providers: `{event_id, provider, severity, category, endpoint, detail}`.
  Deliberately no file/line fields — that's the tier's whole meaning.
- `unmatched_events` — `{count, by_severity}` of events Analyze could
  not honestly attach to this codebase: the provider is absent from the
  scan report entirely, or a breaking event's provider evidence was below
  high confidence (a `medium`/`low` entry is a clue, not proof of use —
  not enough to page someone). Count only, never itemized.
- `not_assessable` — info events, listed: `{event_id, provider, category,
  detail}`.
- `summary` — `impacts_total`, `impacts_by_tier {direct, endpoint}`
  (an impact with any direct row counts as direct), `impacts_by_severity`,
  `provider_level_notices`, `unmatched_events`, `not_assessable`,
  `safe_unlinked`.

Deterministic throughout: same scan report + same events → the same
Impact Report, byte for byte (modulo `analyzed_at`).

## Known limitations (Analyze v1)

- **Wrapper attribution is ONE HOP, same repo.** A caller of a wrapper
  resolves; a caller of a caller does not. Object-method wrappers
  (onyx's `rl_requests.get(...)`) attribute as of v2.5 — through
  MODULE-LEVEL exposures only; instances passed as arguments stay
  out of scope by design. Cross-file class-field inheritance is also
  out of scope.
- **Mapping beyond Stripe is generic-rule only** (see the coverage table)
  — endpoint-tier claims for those providers are existence-guarded but
  not hand-validated.
- **Wrapper modules break the chain**: code that wraps the SDK
  (`billing_utils.charge(...)` calling `stripe.Charge.create` internally)
  attributes impact to the wrapper's stripe call, not to the callers of
  the wrapper. Cross-file call-graph tracing is future work.
- **Python `StripeClient` style** (`client.charges.create`) maps via the
  generic walk; the classic module style is the hand-validated one.
- **No alerting**: the Impact Report is a JSON file. Notification fan-out
  (Slack, PR comments) is Report's job — deliberately not built here.

# The proxy honesty design (the most interesting decision here)

When code calls an API through a *proxy* — a library that forwards your
request to whichever provider you configured at runtime — a scanner faces
a temptation: guess the provider. Browniie refuses, and the refusal is the
product. Three facts are kept strictly separate:

1. **A wrapper hop is a fact — follow it.** If this repo's own
   `rl_requests.get("https://api.notion.com/...")` wraps `requests.get`,
   attributing Notion to the caller is following code that *exists*.
   Allowed, at full confidence, with the hop recorded in `via` so anyone
   can check it.
2. **A proxy dependency is a fact; the provider behind it is not.**
   `litellm.completion(model=<runtime value>)` proves the repo depends on
   litellm as an LLM proxy. It does **not** prove which provider runs. So
   Browniie emits a `proxies` record naming *litellm* and explicitly
   nothing else — "routes LLM calls through litellm here; the specific
   provider is chosen at runtime." That is honest *and* useful: a human
   who reads "you route through litellm and Anthropic shipped a breaking
   change" can check their own config in seconds. Analyze turns this into
   the `proxy_reachable` tier — a notice that names the proxy and its call
   sites, never a fabricated file/line for the provider.
3. **A model-string LITERAL in source is evidence; a convention applied
   to a runtime value is a guess.** `DEFAULT_MODEL = "claude-sonnet-4-…"`
   is a literal that exists — matching it against registry `model_prefixes`
   is the same kind of operation as matching a URL against a domain.
   Allowed, at a clearly-labeled lower tier (`model_literals`). Matching a
   model value that came from a variable, a config read, or a database row
   is forbidden, forever.

The rule underneath all three: **never infer a provider from a value the
code does not contain.** Any time a gap tempts a "it's probably X," the
answer is a proxy record or an honest unknown. On Onyx — where one
`litellm.completion` call carries 100% of chat traffic and Anthropic has
zero SDK footprint — this is what lets Browniie say something true and
useful (251 hardcoded Claude model names + a named litellm dependency)
instead of either staying silent or inventing a call site.

# Report (SWARM step 5)

Report is the layer for humans who will never open a JSON file. It takes
one Impact Report and says what matters, where you already look: a **PR
comment** with the file/line table (the detailed channel), and a **Slack
digest** with the counts and a link (the glanceable channel). Two channels
exist on purpose — repeating the detail in Slack would just build a wall
nobody reads.

## How it decides what to say

One function, `build_content()`, turns an Impact Report into a
channel-agnostic **content object**; each channel only formats it. The
rules are law, carried forward from Analyze:

- **Severity leads.** Breaking impacts always come first; the headline is
  severity-led with strict precedence (breaking → provider notices → safe
  → all clear). Empty severities are omitted — never "0 breaking" noise.
- **Never overclaim.** Provider notices have no file/line because Analyze
  couldn't narrow them; no channel may invent one.
- **Harmless collapses.** Safe and informational changes render as ONE
  counted line. A release window ships hundreds of safe additions;
  itemizing them trains readers to skim past the breaking ones.

## Content object — Report contract v1 (frozen)

- `headline` — one severity-led line.
- `sections` — ordered `{title, severity, rows}`; severities present only:
  - `breaking` rows: `{file, line, provider, tier, detail}` — one per
    affected code location.
  - `provider` rows: `{provider, count, detail}` — one per provider,
    `detail` is the first event's text as a concrete example. No
    file/line, ever.
  - `info` rows: exactly one — `{count, safe_impacts, safe_unlinked,
    not_assessable}`.
- `footer` — `{impact_report, analyzed_at, scan_repo, scanned_at,
  events_refs}`: the trail back to the artifact. (Archive commits stay
  per-event in the Watch stream — one canonical pointer per fact.)

Deterministic: same Impact Report + same ref → byte-identical content.

## The channels

**PR comment** (`--pr owner/repo#123`): headline bold; breaking impacts
as a table whose columns include the **endpoint** (`GET
/organization/audit_logs` — the single most actionable fact), capped at
**50 rows** with an honest "Showing 50 of N" overflow line; notices one
bullet per provider with the example endpoint and a docs link from the
registry's `docs_url`; safe/info one human sentence; every section ends
with a one-line call to action; the footer names the bare repo only —
local filesystem paths never reach a comment.
Posting is **idempotent**: every comment ends with a hidden HTML marker
(`<!-- browniie:impact:<hash> -->`, invisible when rendered), and a later
run finds its own comment by that marker and UPDATES it instead of
stacking duplicates — one living Browniie comment per PR. The hash
identifies the scanned repo, so fresh reports supersede old ones.
(Mechanics worth knowing: comments post through GitHub's *issues* API —
for commenting purposes a PR *is* an issue.)

**Slack digest** (`--slack-webhook-config-key KEY`): Block Kit message —
headline, counts, link to the PR comment. Brevity is a *tested property*:
≤ 6 blocks and < 2,000 serialized characters even against the worst
observed real case (1,082 provider notices), asserted in the suite.

## Report setup (one-time)

Secrets live in `report_config.yaml` at the repo root — **gitignored,
never committed**:

```yaml
github_token: "github_pat_..."
slack_webhooks:
  default: "https://hooks.slack.com/services/T000/B000/xxxx"
```

- **GitHub token**: GitHub → Settings → Developer settings → Fine-grained
  tokens → *Generate new token* → Repository access: only the repo(s)
  Browniie will comment on → Permissions: **Issues: Read and write** and
  **Pull requests: Read and write**. Least privilege, same reasoning as
  the archive token.
- **Slack webhook**: create an *incoming webhook* for the channel you
  want the digest in — follow Slack's own guide at
  https://api.slack.com/messaging/webhooks (it walks through creating the
  app and copying the URL; there is nothing Browniie-specific about it).

## You now have the complete pipeline

What a real week looks like:

1. **Daily, automatically**: the GitHub Actions workflow runs a watch
   cycle — every provider spec/changelog fetched, archived, replicated,
   diffed; events land as artifacts.
2. **When you care about a repo** (once, or on each PR): scan it —
   `run_scan.py <repo> -o reports/repo.json`.
3. **When Watch reports changes** (the run summary's `events_total` > 0):
   join them — `run_analyze.py reports/repo.json
   reports/watch/<run>.events.jsonl` — and read the impact summary it
   prints.
4. **Deliver**: `run_report.py reports/analyze/repo.impact.json --pr
   owner/repo#N --slack-webhook-config-key default`. The PR gets the
   file/line table; Slack gets the one-liner; re-running updates in place.
5. **When something breaks upstream**, the PR comment is already pointing
   at the exact lines to fix. (Fixing them automatically is Mend — the
   one SWARM letter still unbuilt.)

## Known limitations (Report v1)

- **Delivery is on-demand**: nothing schedules analyze→report runs yet;
  the daily workflow only runs Watch. Wiring scan→analyze→report into CI
  per subject repo is the obvious next ops step.
- **One comment per scanned repo per PR** — by design (idempotency), but
  it means two different Impact Reports for the same scanned repo
  overwrite each other on the same PR.
- **No retries/rate-limit handling** on delivery: a failed POST is a loud
  error for the caller to re-run.


# Mend (SWARM step 5) — the deterministic fixer

## What Mend is, for a beginner

Watch saw a provider change its API. Analyze proved your code is
affected, with file and line. **Mend turns that into a draft pull
request in your repository** — a version bump where that is provably
safe, review annotations where it is not, and an explanation a
reviewer can act on without opening another tool. It is deterministic
end to end: no LLM, no judgment calls — a version bump plus an
annotation quoting an archived spec fragment is mechanical assembly.

**The governing law: Mend never edits a line it cannot trace to a
fact.** Every planned action cites the Watch event id that motivated
it and the Scan record that located it. An action that cannot name
both cannot exist (asserted in code, refused at validation).

## The three tiers, with concrete examples

- **BUMP-SAFE** (may produce a file edit): your `requirements.txt`
  pins `stripe==5.4.0`, Scan located that pin, your `invoices.py:9`
  calls the removed endpoint directly, and the Watch event is a
  confirmed breaking change. The edit is the version token of that
  pin — nothing else — plus a review annotation above the affected
  call. Only EXACT pins are bumped: `stripe>=5.0` is not a pin, and
  rewriting a range would change resolver behavior you chose.
- **ANNOTATE-ONLY** (a comment, never an edit): a config-tier row (the
  URL is *known* to your codebase; no call is proven), a
  `proxy_reachable` or `model_literal` or `framework_http` row, any
  wrapper chain two or more hops deep (`a -> b` — the honest edit
  point is the wrapper, which the row does not locate), and any
  `url_line` split (the URL fact lives on a different line than the
  call — the annotation goes to the FACT line).
- **NEVER-TOUCH** (no edit, no annotation): anything reached only
  through a proxy (litellm routes to a runtime-chosen provider), a
  templated host (`{{ tenant }}.salesforce.com` — resolved by config,
  not by this code), an unknown, or a language Scan does not analyze.

## The four kinds of draft PR content you will see (v25)

At suggest tier and above, one draft PR carries everything Browniie
has for your repo, and each row says plainly which kind it is:

1. **A deterministic fix** — a rule, an admitted recipe, or an
   admitted migration map wrote the diff; no model was involved and
   nothing left your machine. Byte-identical on every run. The only
   kind tier-3 auto may apply.
2. **A partial fix with holes** — everything provable is edited, and
   each genuinely undecidable value is the literal token
   `__BROWNIIE_HOLE__<name>`: it parses, so the diff is reviewable,
   and it cannot run, so it cannot merge quietly. You fill the
   blank; the PR names why it is a blank.
3. **A model-drafted fix** — the deterministic engine refused, and a
   model drafted the change for YOUR review under every structural
   guard. The body names the model, quotes the shape's measured
   evidence (including the wrong rate that keeps it off the
   auto-eligible list, when it is off), and carries the five-point
   reviewer checklist. Never auto-applied, at any setting.
4. **An annotation** — the honest floor: the provider's own guidance
   quoted at your affected line, for the cases where the product was
   retired outright, the value is yours alone to choose, or the
   model honestly declined.

## The coverage floor (repo-level honesty)

If more than **30%** of a repo's recognized source is unanalyzed, the
ENTIRE proposal downgrades to annotate-only and the PR body states
what was and was not covered. Rationale, from the measured corpus:
pre-Ruby Discourse scanned 78% blind and pre-Go Grafana 41% — a
"here is your API exposure, fixed" PR on such a repo would be true
but misleading, the exact failure this project's doctrine exists to
prevent. (Airbyte today sits at 44.8% — its connector CDK is Kotlin —
and Mend correctly refuses to bump it.) Disclosure is unconditional:
the coverage statement appears in every PR body, floor or no floor.

## Sandbox verification — a bump is a claim, so it is checked

Before a bump ships, Mend copies your repo to a temporary directory
(.git excluded), applies the bump THERE, and runs your repo's own
test command (from facts your files state: `package.json` scripts,
`Gemfile` + spec/, `go.mod`, `pom.xml`, pytest config, or unittest
files) **inside a container jail**:

- `--network=none`, always. A test suite that genuinely needs the
  network fails closed and the row downgrades — exactly like a red
  test result. There is deliberately NO opt-in network flag in v1
  (a capability that exists mainly to be left on).
- Non-root user, read-only image filesystem, a bounded tmpfs `/tmp`,
  and CPU/memory/PID limits plus a wall-clock timeout enforced from
  outside (the container is killed by name on expiry). A fork bomb in
  someone's test suite must not take down the host.
- **No host secrets reachable**: the container inherits no environment
  variables, and the only mount is the throwaway repo copy — never
  your home directory, SSH keys, Docker socket, or Browniie's config.
  This is tested by planting a fake secret in the host environment
  and asserting, from inside the jail, that it never arrived.
- **No host fallback exists.** If no container runtime (docker/podman)
  is available, verification reports itself unavailable and every
  bump downgrades to annotate-only, stated in the PR body. Falling
  back to host execution would be the exact silent-danger pattern
  this project forbids, so the capability is absent, not disabled.

Green -> the bump stays, and the PR body records what verified it
(command + "containerized, network=none"). Red, no test command, no
runtime, or timeout -> **the bump is removed** and the row downgrades,
stating plainly what happened. A failed verification in a PR body is
not an error — it is Mend refusing to imply something was checked
when it wasn't.

**Supply chain (v13)**: sandbox images run BY DIGEST, never by tag —
`sandbox_images.yaml` records the manifest-list digest for each
image, and an entry without a digest makes verification unavailable
rather than pulling a mutable tag. The repo copy is bound READ-ONLY
at `/src`; the suite runs in a memory-backed tmpfs the startup shim
populates, so nothing the sandbox writes ever touches durable
storage, and the tmpfs sizes (1 GB work, 256 MB tmp) are hard disk
quotas.

**Honest limits**: a container is strong isolation, not a security
boundary against a determined attacker — kernel escapes exist, and a
digest pin proves you run the bytes you recorded, not that those
bytes are benign. The copy still guarantees your working tree is
never written (byte-compared in tests). Run Mend only against
repositories whose tests you would run by hand.

## The draft-PR-only guarantee

Every Mend output is a **draft pull request** on a dedicated branch
(`browniie/mend-<subject-hash>` — deterministic, no random suffixes).
Re-running updates the same branch and PR (the Report layer's
subject-scoped marker, embedded last in the body). **There is no
auto-merge, no push to a default branch, no force-push, and no
review-approval capability in this codebase at all** — the capability
is absent, not disabled, so no flag or bug can enable it. A human
reviews, edits, and merges — or closes.

## Privacy — the property a security reviewer should check first

No customer source code ever leaves the machine: no LLM, no
telemetry, deterministic local analysis. Every artifact Browniie
emits — scan reports, impact reports, Mend plans, PR bodies —
carries **metadata about code, never code contents**: file paths,
line numbers, dotted call chains, provider names, API hosts, event
ids, version strings. Source lines, config values, and secrets are
not metadata and never appear. The single deliberate exception is
the diff inside the pull request itself — a diff necessarily contains
the changed lines, and it lives only in your own repository.

This is TESTED, not intended: a fixture repo plants a fake credential
directly beside a matched URL and beside a manifest pin, and the
suite asserts the credential appears in no plan, no PR body, no
title, no branch name, no annotation. URLs are query-stripped
everywhere (`?key=...` never survives — the extract_url_token
precedent extended to Mend); absolute local paths never appear (the
Report v1 leak, re-tested here); sandbox test OUTPUT (which can quote
source) is never recorded — only the command label and exit result.

## Running Mend

```
# 1. Plan (zero writes; plan lands in gitignored reports/mend/)
.venv/bin/python run_mend.py plan reports/analyze/myrepo.impact.json \
    reports/myrepo.json --repo /path/to/repo \
    [--bump-target stripe=7.0.0]

# 2-4 (render to disk, sandbox-verify, open the ONE draft PR) are
# exercised by the test suite and the dogfood run; the draft-PR opener
# requires a GitHub token with contents:write + pull_requests:write on
# the TARGET repo only (a fine-grained PAT scoped to that repo), read
# from report_config.yaml (gitignored) exactly like Report's token.
```

**Where the bump version comes from — what Mend can and cannot know.**
Without `--bump-target`, Mend derives a candidate from facts: the
package registry's own release dates (PyPI/npm/RubyGems/Go
proxy/Maven Central), taking the earliest STABLE release published on
or after the Watch event's detection time — the first release that
could have shipped against the changed API. The plan and PR body
record this as `bump_target_source`, so a reviewer sees exactly why
that number was chosen. It is a deliberately weak mapping, stated as
such: **Mend verifies your test suite passes with the new version; it
cannot verify the version resolves the specific breaking change** —
registries publish dates, not intent. Where no stable release
postdates the event (or the lookup fails), the row downgrades:
"target version must be supplied; no source-backed mapping
available." `--bump-target` still works and is recorded as "supplied
by operator". An unsourced version never reaches an edit silently.

## What Mend cannot fix (v1 honest limitations)

- Anything requiring judgment about a REPLACEMENT — a removed
  endpoint's successor, a renamed field's new name — is out of scope
  for a deterministic v1. Mend tells the reviewer what changed
  upstream (quoting the archived spec fragment, verifiable via its
  archive commit) and where the code touches it; choosing the
  rewrite is human work.
- Call-site rewrites are never attempted, at any tier.
- Wrapper DEFINITIONS are not edited: a deep-via row annotates and
  explains rather than guessing at the wrapper's location.
- Range specifiers, lockfiles, and unpinned dependencies are not
  bumped — only exact pins whose anchor Mend re-validated against the
  repo at render time.

# Ops — what runs where, daily

- **Scheduler**: `.github/workflows/watch.yml` runs one full watch cycle
  every day at 06:17 UTC (and on demand via the Actions tab → watch →
  Run workflow). The workflow file is commented line by line — it is also
  the teaching document for GitHub Actions.
- **Data**: raw snapshots go to the **archive repo**
  (`browniie-archive`, pushed by the cycle itself via
  `watch_config.yaml`'s `archive_replication`); events + run summaries are
  uploaded as 90-day workflow **artifacts** — deterministic derivatives,
  re-derivable from the archive forever.
- **Checking on it**: the Actions tab shows every run; a failed run emails
  the repo owner (that IS v1 alerting, by design). The run summary prints
  fetch/change/event counts and the archive-push outcome.
- **Recovering the archive** on a new machine:
  `git clone git@github.com:<user>/browniie-archive.git archive` inside
  the project — the next `run_watch.py` picks it up exactly where it
  left off (state lives in the archive, not the machine).
- **The guard**: the workflow refuses to run if replication is disabled —
  an unpushed CI archive commit dies with the runner, and green-but-
  discarding would be the worst failure this codebase can have.
