Skip to content

feat: deterministic package to repo confidence scoring (CM-1306) - #4544

Draft
joanagmaia wants to merge 1 commit into
mainfrom
feat/CM-1306-repo-confidence-scoring
Draft

feat: deterministic package to repo confidence scoring (CM-1306)#4544
joanagmaia wants to merge 1 commit into
mainfrom
feat/CM-1306-repo-confidence-scoring

Conversation

@joanagmaia

@joanagmaia joanagmaia commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Part 1 of 3 for CM-1392.

Stack: #4544 (this PR)#4545#4546. Merge in that order.

Problem

package_repos is the seam every consumer (Akrites, Insights, criticality, blast radius) reads to answer "which repo does this package come from". The read side takes the highest-confidence link, but all nine registry writers hardcoded 0.8 and deps.dev carried its own provenance→confidence CASE. A squatting package and the legitimate publisher were indistinguishable, and links that tied on 0.8 resolved arbitrarily. Repos accumulated inflated packages_published counts, and every metric derived from those aggregates inherited the error.

What this does

One IMMUTABLE SQL function, package_repo_confidence(...), becomes the only producer of a package_repos.confidence value. Every writer calls it — the eight registry loops, the deps.dev staging merge, and the enricher rescore.

  • Base tiers by source (+ provenance for deps.dev): manual 0.99 · SLSA 0.99 · rubygems/pypi attestation 0.95 · GO_ORIGIN 0.90 · other deps.dev 0.50 · declared 0.85 (maven 0.80) · heuristic 0.30.
  • Penalties, stacked, floored at 0.05: signal='secondary' −0.10, ownership_match='unmatched' −0.25 / 'no_evidence' −0.10, archived −0.20, fork −0.10, disabled → flat 0.05, non-GitHub host with a competing GitHub link −0.05.
  • New columns signal, ownership_match, provenance. The first two default to their no-op value and are only written by the two follow-up PRs, so this change is behaviour-neutral on merge apart from the tier/penalty values themselves.
  • Uniqueness offset source_priority * 1e6 + repo_id % 1e6 scaled 1e-9 (max 0.003999999), bounded below the 0.05 tier gap and below both label boundaries, so ranking is total without ever crossing a tier or a High/Medium/Low label. Reachable range 0.050.993999999.
  • Keep-highest everywhere: confidence = GREATEST(EXCLUDED.confidence, package_repos.confidence), and the descriptive columns only move when the new score wins. A weaker source can no longer overwrite a stronger one.
  • One read ordering: bestRepoLinkOrderBy / BEST_REPO_LINK_JOIN replace five inline copies with divergent tie-breakers. The fragment keeps ROUND(confidence, 2), so API payloads stay at two decimals.
  • Rescore on repo-state change: repo state is NULL until the enricher runs, so writers score with what exists and the enricher rescores that repo's links when archived / disabled / is_fork flip.

Migration — needs a maintenance window

confidence is widened in place, numeric(3,2)numeric(12,9). The scale change rewrites the table under ACCESS EXCLUSIVE, and package_repos carries REPLICA IDENTITY FULL plus a Sequin publication (V1781009234).

Run with the Sequin and Tinybird sinks paused. The migration header says so too.

Existing rows keep their values until rescore_package_repo_confidence() backfills them — keyset-paged, COMMIT per chunk, session advisory lock, run out-of-band via scripts/rescorePackageRepos.ts, which also reports the no-ties invariant.

Decision record

ADR-0020 — includes why the scoring lives in SQL rather than TypeScript, and why the dual-column confidence_score alternative was dropped in favour of the in-place widening.

Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces centralized, deterministic package-to-repository confidence scoring across the packages pipeline.

Changes:

  • Adds the scoring migration, backfill procedure, and claim types.
  • Migrates registry writers and readers to shared scoring and ordering.
  • Adds rescore tooling, tests, and ADR documentation.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
backend/src/osspckgs/migrations/V1788307200__package_repo_confidence_scoring.sql Adds schema, scoring, indexes, and backfill procedure.
docs/adr/0020-package-repo-confidence-scoring.md Records the scoring architecture decision.
docs/adr/README.md Indexes ADR-0020.
services/apps/packages_worker/package.json Adds rescore commands.
services/apps/packages_worker/src/cargo/enrich.ts Uses centralized scoring for Cargo links.
services/apps/packages_worker/src/deps-dev/queries/packageReposSql.ts Exports deps.dev provenance.
services/apps/packages_worker/src/deps-dev/workflows/ingestRepos.ts Scores deps.dev links during merge.
services/apps/packages_worker/src/enricher/updateEnrichedRepos.ts Triggers scoring after enrichment.
services/apps/packages_worker/src/go/activities.ts Migrates Go link writes.
services/apps/packages_worker/src/maven/runMavenEnrichmentLoop.ts Migrates Maven link writes.
services/apps/packages_worker/src/npm/upsertPackage.ts Migrates npm link writes.
services/apps/packages_worker/src/nuget/runNuGetEnrichmentLoop.ts Migrates NuGet link writes.
services/apps/packages_worker/src/packagist/upsertPackageInfo.ts Migrates Packagist link writes.
services/apps/packages_worker/src/packagist/__tests__/persistPackageInfo.test.ts Updates Packagist mocks and assertions.
services/apps/packages_worker/src/pypi/upsertProject.ts Migrates PyPI link writes.
services/apps/packages_worker/src/rubygems/runRubyGemsCoreLoop.ts Migrates RubyGems link writes.
services/apps/packages_worker/src/scripts/rescorePackageRepos.ts Adds backfill and tie-check tooling.
services/apps/packages_worker/src/security-contacts/ingestSingle.ts Uses shared best-link ordering.
services/libs/data-access-layer/src/osspckgs/api.ts Consolidates repository-link ordering.
services/libs/data-access-layer/src/osspckgs/repos.ts Removes the legacy Maven upsert.
services/libs/data-access-layer/src/osspckgs/sqlFragments.ts Adds shared ordering SQL.
services/libs/data-access-layer/src/osspckgs/stewardships.ts Uses shared ordering.
services/libs/data-access-layer/src/osspckgs/types.ts Removes the obsolete upsert type.
services/libs/data-access-layer/src/packages/index.ts Exports confidence utilities.
services/libs/data-access-layer/src/packages/repoConfidence.test.ts Tests labels, claims, and SQL generation.
services/libs/data-access-layer/src/packages/repoConfidence.ts Defines claims and scoring SQL helpers.
services/libs/data-access-layer/src/packages/repoConfidenceScoring.integration.test.ts Tests database scoring behavior.
services/libs/data-access-layer/src/packages/repos.ts Centralizes link upserts and rescoring.
Suppressed comments (2)

backend/src/osspckgs/migrations/V1788307200__package_repo_confidence_scoring.sql:117

  • repo_id % 1000000 does not make confidence unique: two links whose repo IDs differ by one million receive the same offset whenever their source priority and adjusted base match. That contradicts the total-ranking/no-ties invariant and can still leave downstream consumers with ambiguous scores. Preserve the full repo ID in a sufficiently precise bounded offset, or retain a separate deterministic tie-break key across every consumer.
    -- Two repos on the same package collide only if their ids are congruent mod 1e6
    -- and their sources share a priority band.
    offset_units := source_priority::bigint * 1000000 + COALESCE(p_repo_id, 0) % 1000000;

    RETURN LEAST(base + offset_units * 0.000000001, 0.999999999);

backend/src/osspckgs/migrations/V1788307200__package_repo_confidence_scoring.sql:163

  • The procedure has the same invalid UPDATE ... FROM LATERAL correlation: pr is the update target, not a preceding FROM item visible to the lateral subquery. Consequently the first backfill batch fails instead of rescoring any rows. Move the scoring call into the SET expression.
               SET confidence = s.confidence
              FROM batch b, packages p, repos r,
                   LATERAL (
                     SELECT package_repo_confidence(
                       pr.source, p.ecosystem, pr.signal, pr.ownership_match, pr.provenance,

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +69 to +72
it('scores the source tiers', async () => {
expect(await score({ source: 'manual' })).toBeCloseTo(0.99, 3)
expect(await score({ source: 'deps_dev', provenance: 'SLSA_ATTESTATION' })).toBeCloseTo(0.99, 3)
expect(await score({ source: 'deps_dev', provenance: 'GO_ORIGIN' })).toBeCloseTo(0.9, 3)
Comment on lines +134 to +142
`UPDATE package_repos pr
SET confidence = s.confidence
FROM packages p, repos r,
LATERAL (
SELECT ${packageRepoConfidenceCall('p', 'r', claimFromRow('pr'))} AS confidence
) s
WHERE p.id = pr.package_id
AND r.id = pr.repo_id
AND pr.repo_id = ANY($(repoIds)::bigint[])`,
Comment on lines +17 to +20
-- Ownership evidence for a declared link. Real values written by CM-1394;
-- 'no_evidence' until then, which is the no-op default for scoring.
ADD COLUMN IF NOT EXISTS ownership_match text NOT NULL DEFAULT 'no_evidence'
CHECK (ownership_match IN ('matched', 'unmatched', 'no_evidence')),
Comment on lines +88 to +90
IF p_disabled IS TRUE THEN
RETURN 0.05;
END IF;
Comment on lines 253 to +257
ON CONFLICT (package_id, repo_id) DO UPDATE SET
confidence = GREATEST(EXCLUDED.confidence, package_repos.confidence),
verified_at = NOW()
source = CASE WHEN EXCLUDED.confidence > package_repos.confidence
THEN EXCLUDED.source ELSE package_repos.source END,
signal = CASE WHEN EXCLUDED.confidence > package_repos.confidence
THEN EXCLUDED.signal ELSE package_repos.signal END,
Comment on lines +105 to +109
CROSS JOIN LATERAL (
SELECT ${packageRepoConfidenceCall('p', 'r', {
source: `'deps_dev'`,
signal: `'primary'`,
ownershipMatch: `'no_evidence'`,
Comment on lines +76 to +79
await rescorePackageReposForRepos(
qx,
(updated as { id: string }[]).map((r) => r.id),
)
Comment on lines +84 to +87
scored AS (
SELECT ${packageRepoConfidenceCall('p', 'r')} AS confidence
FROM packages p, repos r
WHERE p.id = $(packageId)::bigint AND r.id = $(repoId)::bigint
Comment on lines +64 to +72
// Confidence is never passed in — package_repo_confidence() (V1788307200) is the only
// path that produces one. Callers describe the claim (source, which manifest field it
// came from, what ownership evidence backs it) and the function scores it against the
// package's ecosystem and the repo's current state.
//
// Conflict policy, uniform across every writer: keep the highest-scoring claim and adopt
// that claim's provenance. Writer order is irrelevant — a routine registry refresh cannot
// downgrade a link a stronger source (manual, an attested deps.dev row) already owns, and
// a stronger claim arriving later takes the row over completely.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants