@@ -281,3 +281,108 @@ derived at read time as the band of the highest contact score, not stored.
281281- ** Source-format drift** — SECURITY-INSIGHTS schema versions, registry API shapes, and GitHub's
282282 ` stats/contributors ` 202-polling behavior all change over time. Extractor isolation limits blast
283283 radius to one source; fixture-based tests catch parser regressions.
284+
285+
286+ ## Addendum (2026-07-29): Vulnerability reporting protocol
287+
288+ Adds a sister data model answering "** how** does this project expect external vulnerability
289+ reporting?" per repo — distinct from security contacts, which answer * who* . The source of truth
290+ is what the project itself declared: security files from the enricher's ` repo_well_known_files `
291+ inventory, the pages they link to, and the authoritative ` pvr_enabled ` flag. Inferred contacts
292+ from ` security_contacts ` never blend in as if declared; they appear only as clearly-labeled
293+ fallback when nothing was declared.
294+
295+ ### Volume and parser split (prod analysis, 2026-07-28)
296+
297+ Of 114,045 critical GitHub repos, 10,349 (9.1%) have a security file: 10,495 files collapsing
298+ to 6,125 distinct blobs (top-20 shared blobs cover ~ 1,900 repos of boilerplate). A probe over
299+ all 6,120 reachable blobs showed ** 69.2% deterministically resolvable** (a single declared
300+ method, or several with exactly one preference-cued), ** 14% pointer-only** (the file is just a
301+ link to an external policy page), ** 21.6% with conditional routing** ("only email if a GHSA is
302+ not possible"), ** 53% with negation language** ("do NOT open a public issue"), 2.3% GitHub
303+ default template. Volume is not the constraint; precision is — hence ** hybrid,
304+ deterministic-first** : the classifier fully settles clean blobs, an LLM handles the residue and
305+ prose fields, and a deterministic validator gates every LLM write.
306+
307+ ### Data model
308+
309+ - ** ` security_policy_parses ` ** — content-keyed parse cache. PK ` blob_oid ` (git blob oid for
310+ files; sha256 of the URL for linked pages, so two URLs with identical content stay
311+ independently joinable from ` linked_urls ` ), ` source_kind `
312+ (` security-file ` /` linked-page ` ), ` url ` (linked-page rows), ` parser `
313+ (` deterministic ` /` llm ` ), ` parser_version ` , ` status ` (` ok ` /` template ` /` degraded ` ), ` parsed `
314+ JSONB (methods + guidelines), ` linked_urls ` . Identical content across repos is parsed once,
315+ ever; a ` parser_version ` bump is a targeted re-parse, not a migration.
316+ - ** ` repo_reporting_protocols ` ** — assembled per-repo answer. PK ` repo_id ` , ` declared ` ,
317+ ` methods ` JSONB (ordered array of `{type, status, endpoint, condition, confidence,
318+ provenance}` ), ` guidelines` JSONB, ` sources` JSONB, ` assembled_at` . Method ` type` ∈
319+ github-pvr | email | web-form | bounty-platform | security-txt | mailing-list; ` status ` ∈
320+ preferred | accepted | fallback | prohibited (` prohibited ` captures negation language);
321+ ` confidence ` ∈ declared | inferred. Plain upsert — fully derived and recomputable, no
322+ soft delete.
323+
324+ ### Parse stage (blob-driven)
325+
326+ ` repo_well_known_files ` is the work queue (live ` security ` rows for critical GitHub repos whose
327+ ` blob_oid ` lacks a parse at the current version); this pipeline never probes repos for files.
328+ Blobs are fetched once by oid through the shared GitHub gateway. The classifier (same
329+ windowing family as the B1 extractor) emits a ` clean ` verdict — single usable method, or
330+ exactly one preference-cued among several, no conditional language, negation on a method's own
331+ line marks it ` prohibited ` — which is stored as-is. Residue goes to the LLM; the validator
332+ requires every emitted endpoint to appear in the source (URLs verbatim; emails also via
333+ deobfuscation normalization — "security at python dot org"), valid enums, and at most one
334+ ` preferred ` — failures are stored ` status='degraded' ` (classifier partials, no guidelines).
335+ The LLM can never invent a channel. Pointer-only parses record up to 3 linked URLs; each
336+ linked page is fetched once per URL (SSRF-guarded: http(s) only, private/loopback/link-local
337+ and metadata hosts blocked, redirects revalidated per hop, body capped at 500 KB while
338+ streaming) and parsed as a ` linked-page ` row. For a pointer-only blob the file row is written
339+ only after every linked page has a parse row, so a transient page failure leaves the blob
340+ unmarked and the next daily sweep retries the whole unit. Batches are drawn in random order so
341+ permanently failing blobs cannot starve the queue.
342+
343+ ### Assembly
344+
345+ Repos are re-assembled when inputs change (no protocol row, ` contacts_last_refreshed ` newer
346+ than ` assembled_at ` , or a newer parse for one of their blobs). Merge rules: ` ok ` /` template `
347+ parses contribute methods and guidelines with provenance — ** ` degraded ` parses contribute
348+ nothing** ; ` pvr_enabled = true ` adds a ` github-pvr ` method when the files are silent, and
349+ ` pvr_enabled = false ` ** vetoes** a declared github-pvr method (the A2-vetoes-B1 rule applied
350+ to the protocol); github-pvr sentinel endpoints are rewritten per repo to
351+ ` …/security/advisories/new ` ; dedup on type+endpoint; at most one ` preferred ` ; sort preferred >
352+ accepted > fallback > prohibited. Only when nothing is declared: up to 3 ` inferred ` /` fallback `
353+ methods derived from live ` security_contacts ` (email, github-pvr, web-form channels, by score).
354+ Every repo in the population gets a row — ` declared=false ` with an empty ` methods ` array for
355+ the ~ 89 no-signal repos.
356+
357+ ### LLM contract
358+
359+ Direct AWS Bedrock calls (` @aws-sdk/client-bedrock-runtime ` , module-local in ` llmExtract.ts ` )
360+ — deliberately ** not** the legacy class-based ` LlmService ` in ` common_services ` (class pattern
361+ + prompt-history DB coupling) and ** not** a shared provider-agnostic lib speaking to a LiteLLM
362+ proxy (built during implementation, then dropped: no LiteLLM infra today; revisit if CDP
363+ standardizes multi-provider LLM infrastructure — schema and prompt carry over unchanged).
364+ Existing ` CROWD_AWS_BEDROCK_ACCESS_KEY_ID ` /` CROWD_AWS_BEDROCK_SECRET_ACCESS_KEY ` credentials;
365+ default ` LlmModelType.CLAUDE_HAIKU_4_5 ` with region from ` LLM_MODEL_REGION_MAP ` . The JSON
366+ schema is embedded in the system prompt (Bedrock InvokeModel has no structured-output mode);
367+ ` parseLlmJson ` parses the answer. Missing credentials or any failure → ` degraded ` parse, never
368+ a thrown error. No prompt-history persistence.
369+
370+ ### Scheduling
371+
372+ Own Temporal schedule ` reporting-protocol-ingestion ` (daily 07:00, ` SKIP ` overlap, 24 h
373+ execution timeout) inside the security-contacts worker, independent of the contacts schedule so
374+ a slow LLM pass never stalls contact ingestion. The workflow drains parsing first
375+ (` continueAsNew ` while a batch parsed anything; an all-failed batch falls through to assembly
376+ instead of recursing — failed blobs get no row and retry on the next daily tick), then drains
377+ assembly. Batch sizes: 200 blobs (parse), 2,000 repos (assemble). Both activities ride the
378+ shared 30-minute proxy and heartbeat on a fixed 30 s cadence under its 2-minute
379+ ` heartbeatTimeout ` .
380+
381+ ### Deferred
382+
383+ Other interaction-profile domains (contribution intake, governance, maintainer roster,
384+ communication channels, code of conduct — the content-keyed cache and section pattern extend
385+ to them); org-level ` .github ` default files (GitHub serves them for repos without their own
386+ SECURITY.md; the inventory doesn't capture them — measure the gap first); non-GitHub declared
387+ parsing (no file inventory; such repos assemble as ` declared=false ` + inferred fallback); API
388+ exposure on the akrites endpoints.
0 commit comments