Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
-- Ownership-evidence matching for package→repo links (CM-1394).
--
-- The per-package registry workers call matchOwnership() in TypeScript
-- (packages_worker/src/utils/ownershipMatch.ts). Cargo is the exception: its
-- pipeline is set-based over the crates.io dump and cannot call a parser per row,
-- so it needs the same normalization available in SQL. Both sides must stay in
-- step — the vanity suffix list and the prefix rule below mirror that module.

ALTER TABLE package_repos
ADD COLUMN IF NOT EXISTS ownership_match text NOT NULL DEFAULT 'no_evidence'
CHECK (ownership_match IN ('matched', 'unmatched', 'no_evidence'));
Comment thread
cursor[bot] marked this conversation as resolved.

CREATE OR REPLACE FUNCTION package_repo_owner_key(p_identity text)
RETURNS text
LANGUAGE sql IMMUTABLE AS $$
WITH base AS (
SELECT regexp_replace(lower(btrim(COALESCE(p_identity, ''))), '^@', '') AS v
),
trimmed AS (
SELECT v, substring(v from '-(?:ai|io|team|labs|oss|dev)$') AS suffix FROM base
)
SELECT NULLIF(
regexp_replace(
CASE
WHEN suffix IS NOT NULL AND length(v) > length(suffix) + 1
THEN left(v, length(v) - length(suffix))
ELSE v
END,
'[^a-z0-9]', '', 'g'
),
''
)
FROM trimmed;
$$;

CREATE OR REPLACE FUNCTION package_repo_owner_match(p_repo_owner text, p_candidates text[])
RETURNS text
LANGUAGE plpgsql IMMUTABLE AS $$
DECLARE
owner_key text;
keys text[];
BEGIN
owner_key := package_repo_owner_key(p_repo_owner);
IF owner_key IS NULL THEN
RETURN 'no_evidence';
END IF;

SELECT array_agg(k) INTO keys
FROM (
SELECT package_repo_owner_key(c) AS k
FROM unnest(COALESCE(p_candidates, '{}'::text[])) AS c
) t
WHERE k IS NOT NULL;

IF keys IS NULL OR cardinality(keys) = 0 THEN
RETURN 'no_evidence';
END IF;

-- Prefix equality on at least 4 characters: `tokio` matches `tokio-rs`, while
-- short keys stay exact so `ab` cannot claim `abcdef`.
IF EXISTS (
SELECT 1
FROM unnest(keys) AS k
WHERE k = owner_key
OR (length(k) <= length(owner_key) AND length(k) >= 4 AND owner_key LIKE k || '%')
OR (length(owner_key) < length(k) AND length(owner_key) >= 4 AND k LIKE owner_key || '%')
) THEN
RETURN 'matched';
END IF;

RETURN 'unmatched';
END;
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
-- Enable the no_evidence ownership penalty (CM-1394).
--
-- V1788393600 added the ownership_match column; V1788307300 added the signal
-- parameter. This migration widens the scoring function to read ownership_match
-- too and activates the no_evidence branch — deferred until now because no writer
-- set the column until CM-1394 landed.

-- Adding a parameter changes the signature, so the V1788307300 function is dropped
-- rather than replaced — CREATE OR REPLACE would leave both overloads callable.
-- The 9-arg compat overload from V1788307300 depends on the 10-arg one below, so it
-- must go first or the DROP FUNCTION on the 10-arg signature fails on that dependency.
DROP FUNCTION IF EXISTS package_repo_confidence(
text, text, text, bool, bool, bool, text, bool, bigint
);
DROP FUNCTION IF EXISTS package_repo_confidence(
text, text, text, text, bool, bool, bool, text, bool, bigint
);
Comment thread
joanagmaia marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
joanagmaia marked this conversation as resolved.

CREATE OR REPLACE FUNCTION package_repo_confidence(
p_source text,
p_ecosystem text,
p_signal text,
p_ownership_match text,
p_provenance text,
p_archived bool,
p_is_fork bool,
p_disabled bool,
p_host text,
p_competing_github bool,
p_repo_id bigint
)
RETURNS numeric(12, 9)
LANGUAGE plpgsql IMMUTABLE AS $$
DECLARE
base numeric;
source_priority int;
offset_units bigint;
BEGIN
base := CASE p_source
WHEN 'manual' THEN 0.99
WHEN 'heuristic' THEN 0.30
WHEN 'deps_dev' THEN CASE p_provenance
WHEN 'SLSA_ATTESTATION' THEN 0.99
WHEN 'RUBYGEMS_PUBLISH_ATTESTATION' THEN 0.95
WHEN 'PYPI_PUBLISH_ATTESTATION' THEN 0.95
WHEN 'GO_ORIGIN' THEN 0.90
ELSE 0.50
END
-- maven splits off npm/cargo/the rest: POM <scm> blocks are notoriously
-- stale (legacy SVN URLs, org renames, dead mirrors).
WHEN 'declared' THEN CASE WHEN p_ecosystem = 'maven' THEN 0.80 ELSE 0.85 END
ELSE 0.30
END;

-- Signal and ownership adjust the declared tier only. A deps.dev publish
-- attestation already proves the publisher–repo relationship, and manual links
-- are operator-pinned.
IF p_source = 'declared' THEN
IF p_signal = 'secondary' THEN
base := base - 0.10;
END IF;

IF p_ownership_match = 'unmatched' THEN
base := base - 0.25;
ELSIF p_ownership_match = 'no_evidence' THEN
base := base - 0.10;
END IF;
END IF;

source_priority := CASE p_source
WHEN 'manual' THEN 3
WHEN 'deps_dev' THEN 2
WHEN 'declared' THEN 1
ELSE 0
END;

IF p_disabled IS TRUE THEN
-- Scale proportionally so pre-disabled claim ordering is preserved across sources.
-- The offset uses a tighter modulo so max contribution (3*1000+999)*1e-9 ≈ 4e-6
-- stays below the 0.00016 minimum scaled tier gap and cannot invert source ordering.
base := 0.05 + LEAST(base, 0.99) * 0.004;
offset_units := source_priority::bigint * 1000 + COALESCE(p_repo_id, 0) % 1000;
ELSE
IF p_archived IS TRUE THEN
base := base - 0.20;
END IF;

IF p_is_fork IS TRUE THEN
base := base - 0.10;
END IF;

IF p_competing_github IS TRUE AND p_host IS NOT NULL AND p_host <> 'github' THEN
base := base - 0.05;
END IF;

base := GREATEST(base, 0.05);

-- Tie-breaker: reduces same-source collisions to the rare case where two repo IDs for
-- the same package are congruent mod 1,000,000. BEST_REPO_LINK_JOIN uses a secondary
-- ORDER BY repo_id DESC as the canonical deterministic pick when confidence ties.
offset_units := source_priority::bigint * 1000000 + COALESCE(p_repo_id, 0) % 1000000;
END IF;

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

-- Compat overload for callers still on the pre-ownership-match signature during a rolling
-- deploy; delegates to the widened function as 'no_evidence', the safest default since the
-- ownership penalty only applies to p_source = 'declared' — every other source ignores it.
-- Drop in a later cleanup migration once all writers emit the 11-arg call.
CREATE OR REPLACE FUNCTION package_repo_confidence(
p_source text,
p_ecosystem text,
p_signal text,
p_provenance text,
p_archived bool,
p_is_fork bool,
p_disabled bool,
p_host text,
p_competing_github bool,
p_repo_id bigint
)
RETURNS numeric(12, 9)
LANGUAGE sql IMMUTABLE AS $$
SELECT package_repo_confidence(
p_source, p_ecosystem, p_signal, 'no_evidence', p_provenance,
p_archived, p_is_fork, p_disabled, p_host, p_competing_github, p_repo_id
)
$$;

-- Replaced only to pass cur.ownership_match through to the widened scoring function;
-- the chunking, locking and keyset paging are unchanged from V1788307300.
CREATE OR REPLACE PROCEDURE rescore_package_repo_confidence(
p_repo_ids bigint[] DEFAULT NULL,
chunk_size int DEFAULT 25000,
INOUT applied_rows int DEFAULT 0
)
LANGUAGE plpgsql AS $$
DECLARE
batch_rows int;
updated_rows int;
cursor_id bigint := 0;
BEGIN
IF chunk_size IS NULL OR chunk_size <= 0 THEN
RAISE EXCEPTION 'rescore_package_repo_confidence: chunk_size must be positive, got %', chunk_size;
END IF;

-- Session-level: survives the internal COMMITs below.
IF NOT pg_try_advisory_lock(hashtextextended('rescore_package_repo_confidence', 0)) THEN
RAISE EXCEPTION 'rescore_package_repo_confidence: another execution is already in progress';
END IF;

applied_rows := 0;

LOOP
WITH batch AS (
SELECT pr.id
FROM package_repos pr
WHERE pr.id > cursor_id
AND (p_repo_ids IS NULL OR pr.repo_id = ANY(p_repo_ids))
-- deps_dev rows with NULL provenance were ingested before this column existed;
-- skip them so the backfill does not downgrade SLSA/attestation links to 0.50.
-- They will be rescored correctly once the next ingest populates provenance.
AND NOT (pr.source = 'deps_dev' AND pr.provenance IS NULL)
ORDER BY pr.id
LIMIT chunk_size
FOR UPDATE
),
updated AS (
UPDATE package_repos pr
SET confidence = s.confidence,
verified_at = GREATEST(clock_timestamp(), cur.verified_at + interval '1 millisecond')
FROM batch b
JOIN package_repos cur ON cur.id = b.id
JOIN packages p ON p.id = cur.package_id
JOIN repos r ON r.id = cur.repo_id,
LATERAL (
SELECT package_repo_confidence(
cur.source, p.ecosystem, cur.signal, cur.ownership_match, cur.provenance,
r.archived, r.is_fork, r.disabled, r.host,
EXISTS (
SELECT 1
FROM package_repos c
JOIN repos cr ON cr.id = c.repo_id
WHERE c.package_id = cur.package_id
AND c.repo_id <> cur.repo_id
AND cr.host = 'github'
),
cur.repo_id
) AS confidence
) s
WHERE pr.id = b.id
AND s.confidence IS DISTINCT FROM cur.confidence
RETURNING pr.id
)
SELECT COUNT(*), COALESCE(MAX(b.id), cursor_id),
(SELECT COUNT(*) FROM updated)
INTO batch_rows, cursor_id, updated_rows
FROM batch b;

applied_rows := applied_rows + updated_rows;

COMMIT;

EXIT WHEN batch_rows < chunk_size;
END LOOP;

PERFORM pg_advisory_unlock(hashtextextended('rescore_package_repo_confidence', 0));
END;
$$;
105 changes: 105 additions & 0 deletions docs/adr/0022-package-repo-ownership-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# ADR-0022: Ownership evidence for package→repo links

**Date**: 2026-09-01
**Status**: accepted
**Deciders**: Joana Maia

## Context

A package can name any repository it likes. Nothing in the ingestion path ever
checked whether the entity publishing the package has anything to do with the
entity owning the repo, so a package declaring `github.com/torvalds/linux`
produced a link indistinguishable from the kernel's own. That is how unrelated
packages inflate a repo's `packages_published` count and, through it, every
aggregate computed from those counts.

The evidence needed for the check is already ingested: npm scopes, Maven
groupIds, packagist vendors, Go module paths, and maintainer/owner/author logins
for most registries. [ADR-0020](./0020-package-repo-confidence-scoring.md)
reserved the `ownership_match` column and priced it — `unmatched` −0.25,
`no_evidence` −0.10 — so all that is missing is a producer for the value.

## Decision

`matchOwnership({ namespace, maintainers, repoOwner })`
(`packages_worker/src/utils/ownershipMatch.ts`) returns `matched`,
`unmatched`, or `no_evidence`, and every declared writer calls it before
persisting a link. Namespace evidence is checked first, maintainer logins are
the fallback. `no_evidence` is a distinct outcome from `unmatched`: a registry
that exposes nothing to compare must not be treated as a failed comparison.

### Normalisation

Identities are compared after `normalizeIdentity`: trim, lowercase, strip a
leading `@`, strip one trailing vanity suffix from `-ai`, `-io`, `-team`,
`-labs`, `-oss`, `-dev` (only when a suffix strip leaves more than one
character), then drop all non-alphanumerics. Reverse-DNS namespaces (multi-segment, e.g. `org.projectlombok`) contribute
only their identity-bearing segments as candidates — structural labels (`io`,
`com`, `org`, `github`, etc.) are excluded, and no joined form is produced.
Flat single-segment scopes are normalised as a whole string. This means
`io.github.resilience4j` matches `resilience4j` via its identity-bearing
segment, while `io.github.attacker` cannot prefix-match a lookalike owner
such as `io-github`. Two identities match on
equality or on prefix when the shorter is at least 4 characters, which is what
makes `tokio-rs` → `tokio` and `langchain-ai` → `langchain` match while `ab`
against `abcdef` stays `unmatched`.

### Per-ecosystem evidence

| Ecosystem | Namespace evidence | Maintainer evidence |
| --- | --- | --- |
| npm | package scope | `maintainers` |
| pypi | — | maintainer/author names |
| packagist | vendor from `name` | maintainers |
| nuget | — | owners + authors |
| maven | groupId | developer/contributor usernames |
| cargo | — | maintainer GitHub logins |
| go | module path owner, VCS hosts only | — |
| rubygems | — | none at the link-writing loop |

Go derives an owner only for module paths rooted at a known VCS host
(`github.com`, `gitlab.com`, `bitbucket.org`, `codeberg.org`, `gitea.com`,
`git.sr.ht`); a vanity module path would otherwise produce a false `unmatched`.
Rubygems stays `no_evidence` for now — owners are fetched in the critical loop,
not in the core loop that writes the link. Maven's backfill caller passes no
evidence and therefore yields `no_evidence`, not `unmatched`.
Comment thread
joanagmaia marked this conversation as resolved.

Cargo's pipeline is set-based SQL over a dump, so it gets a SQL twin of the
matcher — `package_repo_owner_key(text)` (IMMUTABLE) and
`package_repo_owner_match(repo_owner, candidates[])` — applied as a lateral
join against the staged maintainer logins, with the repo owner carried on
`cargo_sync.repo_choice` (see
[ADR-0021](./0021-secondary-manifest-repository-signal.md)).

## Consequences

### Positive

- Squatting links are separated from legitimate ones by score alone — the read
side needs no filtering, and `ORDER BY confidence DESC LIMIT 1` starts
returning the right repo for the epic's named cases.
- `no_evidence` carries a smaller penalty (−0.10) than `unmatched` (−0.25), so ecosystems with thin metadata are not scored as if they had actively mismatched the owner.
- The matcher is a pure function with standalone unit tests; the SQL twin
mirrors it explicitly rather than approximating it.

### Negative

- Two implementations of one rule (TypeScript and SQL) that must stay in sync.
- Heuristic matching produces false `unmatched` for legitimate publisher/owner
splits, costing those links 0.25.
- Rubygems and the Maven backfill contribute `no_evidence` until their loops
are extended, so their links carry a −0.10 they could avoid.

### Risks

- **The TS and SQL normalisers drift, so cargo scores differently from every
other ecosystem.** Mitigation: both were validated against the same inputs
before merge; any change to the vanity-suffix list or the prefix rule must
touch both, and the cargo integration path is the canary.
- **Vanity-suffix stripping over-matches, e.g. two distinct orgs collapsing to
the same key.** Mitigation: the 4-character floor on prefix matching, and
the penalty is a score adjustment rather than a hard rejection.
- **The `unmatched` distribution is unknown until the migration is applied.**
Mitigation: record the matched/unmatched/no_evidence baseline per ecosystem
before the rescore, so an unexpectedly large `unmatched` share is caught
before the scores reach downstream consumers.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
| [ADR-0019](./0019-docker-builder-runner-libc.md) | Same libc in Docker builder and runner | accepted | 2026-08-27 |
| [ADR-0020](./0020-package-repo-confidence-scoring.md) | Deterministic package→repo confidence scoring | accepted | 2026-09-01 |
| [ADR-0021](./0021-secondary-manifest-repository-signal.md) | Secondary manifest repository signal | accepted | 2026-09-01 |
| [ADR-0022](./0022-package-repo-ownership-evidence.md) | Ownership evidence for package→repo links | accepted | 2026-09-01 |

## Why ADRs?

Expand Down
Loading
Loading