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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,15 @@ SECURITY_EVENT_HASH_KEY=
VITE_POSTHOG_PROJECT_TOKEN=
VITE_POSTHOG_HOST=
VITE_GA_MEASUREMENT_ID=

# Ownership ingestion — Phase 1C internal gate (DO NOT ENABLE in production
# until the ownership release train ships; this is documentation only).
# Hard-off by construction: ingestion is allowed ONLY when BOTH hold —
# 1. OWNERSHIP_INGEST_ENABLED is exactly the literal string 'true'
# 2. the repository's INTERNAL uuid (repositories.id) is listed in
# OWNERSHIP_CANARY_REPOSITORY_IDS
# '1', 'yes', wildcards, names, or logins enable nothing.
OWNERSHIP_INGEST_ENABLED=false
# Comma-separated internal repository UUIDs only — never repo names,
# full_name, or GitHub logins. Empty/malformed = nothing can ingest.
OWNERSHIP_CANARY_REPOSITORY_IDS=
51 changes: 45 additions & 6 deletions docs/ownership-data-privacy-spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ownership Data & Privacy Contract — Production Specification

> **Status: proposed production contract — no ownership persistence implemented yet.**
> **Status: schema foundation deployed; ingestion and UI not yet live.**
>
> This document is the authoritative implementation contract for the Code
> Ownership Over Time feature. It translates the approved architecture
Expand All @@ -9,9 +9,19 @@
> into an exact data contract that Phase 1B (schema + ingestion) and Phase 1C
> (dashboard) must implement and that review must enforce.
>
> **Nothing in this document is live.** Where the text describes ownership
> behavior it describes *future behavior once ownership ships*. Current
> production behavior is described in §2 and cited against real files.
> **What is live today:** migration `012_ownership.sql` is applied in
> production — the four ownership tables exist with RLS/grants/composite-FK
> foundations, and the dormant `dash_ownership_*` SECURITY INVOKER RPCs are
> deployed. All four tables hold **zero rows**.
>
> **What is NOT live:** no ownership ingestion runs; no ownership data is
> collected or written; `OWNERSHIP_INGEST_ENABLED` is not enabled anywhere;
> no ownership UI exists; the public trust record
> (`src/security/SecurityPrivacyPage.tsx`) has NOT been amended — it changes
> only in the release train where collection actually begins (§17). Where
> the text describes ownership behavior it describes *future behavior once
> ownership ships*. Current production behavior is described in §2 and cited
> against real files.

Companion documents:

Expand Down Expand Up @@ -538,6 +548,33 @@ Rules:
by reading back that scope's existing provisional aggregate rows (upserts
are set-valued, idempotent) — raw nodes are never re-storable.

### 9.1 Persistence handoff — hard constraint for Phase 1C-B (BLOCKER)

The Phase 1C-A reducer is correct **only** when it sees the complete node
set for each scope-period it finalizes. The following are hard constraints,
not implementation details, and they block all ownership writes until the
persistence tranche resolves them:

- **Page-local Top-25/Other folding is invalid.** The Top-25 boundary is
defined over a *complete* scope-month's contributor set. Folding per
fetched page fabricates wrong membership (a contributor who would rank
inside the full-month Top-25 can be spilled, or vice versa).
- `human_contributor_count` must be computed over the **full transient
contributor set** for the scope-month — never a page-local or folded
subset. It must never silently cap at the Top-N boundary.
- **Overflow-only contributor identities must not be durably stored merely
to simplify paging** — §6.4 durable-identity minimization is a hard rule,
not a convenience; writing identity rows "temporarily" is still writing.
- **Advancing a GraphQL cursor independently from aggregate state can
double-count or skip a page.** Aggregate written then crash before cursor
→ page reduced twice; cursor written then crash before aggregate → page
never counted. Cursor/checkpoint writes must be **atomic with the
aggregate state they describe**.
- Phase 1C-B must therefore design **and prove** a crash-safe
accumulation/checkpoint strategy — atomic page checkpoints or
equivalent set-valued, idempotent writes — **before any ownership write
path is implemented**. This is an explicit blocker.

---

## 10. Production data model — final candidate
Expand Down Expand Up @@ -762,9 +799,11 @@ Mirroring the audited production convention (§2.4):

---

## 16. Dashboard API contract (specification only — not implemented)
## 16. Dashboard API contract (RPC foundations deployed dormant; consumer not implemented)

A future `dash_ownership_*` RPC family feeds the ownership section. Smallest
The `dash_ownership_*` invoker RPCs are deployed (migration 012) but dormant
— `active_version` is never set, so they return nothing. A future API layer
feeds the ownership section through them. Smallest
useful payload shape per repository:

```jsonc
Expand Down
106 changes: 106 additions & 0 deletions lib/ownership/contributors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Contributor identity classification for ownership ingestion — pure.
//
// Contract (spec §5, §6):
// - linked contributor durable identity is the GitHub GraphQL User.id
// (author.user.id); login is display-only and must never be a join key.
// - email, author/committer names, and numeric databaseId are never
// requested, derived, or stored.
// - author.user == null → ONE unresolved 'unlinked' bucket per repository.
// It is unresolved attribution, NOT a person — never pseudonymized by
// name or email.
// - 'other' is the synthetic aggregate of overflow LINKED contributors.
// - bots are linked identities flagged is_bot; they are not people for
// human-only metric purposes (§6.5).

import { createHash } from 'node:crypto'

export const UNLINKED_KEY = 'unlinked'
export const OTHER_KEY = 'other'

const CONTRIBUTOR_DOMAIN = 'devledger-ownership-contributor-v1'

const sha256 = (s) => createHash('sha256').update(s).digest('hex')

// Repository-scoped opaque contributor key. Deterministic across processes,
// stable under login renames (keyed on the node id only), and scoped by the
// repository boundary so the same GitHub account never shares a key across
// repositories. Logins/emails/names never enter the hash.
export function contributorKey(repositoryId, githubNodeId) {
if (typeof repositoryId !== 'string' || repositoryId === '') {
throw new TypeError('contributorKey requires a repository id')
}
if (typeof githubNodeId !== 'string' || githubNodeId === '') {
throw new TypeError('contributorKey requires a GitHub node id')
}
return sha256(`${CONTRIBUTOR_DOMAIN}\0${repositoryId}\0${githubNodeId}`)
}

// Conservative bot heuristic on the login only: GitHub App/bot accounts use
// the conventional '[bot]' login suffix (dependabot[bot], github-actions[bot]
// …). No broader username guessing — a login merely containing "bot" is not
// classified as a bot.
export function isBotLogin(login) {
return typeof login === 'string' && /\[bot\]$/i.test(login.trim())
}

// Synthetic bucket shapes — fixed fields, never carry GitHub identity.
export function unlinkedContributor() {
return {
identity_kind: 'unlinked',
contributor_key: UNLINKED_KEY,
github_node_id: null,
display_login: null,
is_bot: false,
}
}

export function otherContributor() {
return {
identity_kind: 'aggregate',
contributor_key: OTHER_KEY,
github_node_id: null,
display_login: null,
is_bot: false,
}
}

// Provider data that violates the identity contract is a data-contract
// error, never unresolved attribution. The message deliberately carries no
// node id, login, repository id, path, or response content.
export class OwnershipIdentityError extends Error {
constructor() {
super('malformed contributor identity in GitHub response')
this.name = 'OwnershipIdentityError'
}
}

// Classify one history/blame node's author.user into a contributor identity.
// authorUser == null/undefined → the single unlinked bucket
// { id: <nonempty str>, login: <nonempty str> } → linked contributor
// anything else (missing/empty/wrong-typed id or login, non-object value)
// → OwnershipIdentityError — 'unlinked' means ONLY that GitHub returned
// no linked user; malformed provider data must never silently become
// unresolved attribution.
export function classifyAuthorIdentity(authorUser, repositoryId) {
if (authorUser === null || authorUser === undefined) return unlinkedContributor()
if (typeof authorUser !== 'object') throw new OwnershipIdentityError()
const { id, login } = authorUser
if (typeof id !== 'string' || id.trim() === ''
|| typeof login !== 'string' || login.trim() === '') {
throw new OwnershipIdentityError()
}
return {
identity_kind: 'linked',
contributor_key: contributorKey(repositoryId, id),
github_node_id: id,
display_login: login,
is_bot: isBotLogin(login),
}
}

// Human-only metric basis (§6.3, §6.5): concentration/count inputs include
// only linked non-bot identities — unlinked, aggregate, and bots are never
// people for these metrics.
export function isHumanMetricBasis(identity) {
return identity.identity_kind === 'linked' && identity.is_bot === false
}
62 changes: 62 additions & 0 deletions lib/ownership/gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Hard-off feature gate for ownership ingestion.
//
// Contract (docs/ownership-data-privacy-spec.md §21-22): ownership work is
// allowed ONLY when BOTH hold:
// 1. OWNERSHIP_INGEST_ENABLED is exactly the literal string 'true'
// 2. the target repository's INTERNAL uuid is present in
// OWNERSHIP_CANARY_REPOSITORY_IDS (comma-separated)
// Every other state — absent, false, malformed, empty allowlist, repo not
// listed — means DISABLED. No truthiness, no wildcards, no names/logins.
//
// This module is the single entry point a future runner must call BEFORE any
// ownership GitHub request or ownership DB write. Phase 1C-A ships no runner
// and no caller, so the gate can only ever evaluate false in production.

export const OWNERSHIP_FLAG_ENV = 'OWNERSHIP_INGEST_ENABLED'
export const OWNERSHIP_ALLOWLIST_ENV = 'OWNERSHIP_CANARY_REPOSITORY_IDS'

// Canonical uuid shape (8-4-4-4-12 hex). Internal repositories.id values are
// Postgres uuids; anything that is not uuid-shaped is rejected, never coerced.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

export class OwnershipDisabledError extends Error {
constructor() {
// deliberately carries no repository id, flag value, or allowlist content
super('ownership ingestion is disabled for this repository')
this.name = 'OwnershipDisabledError'
}
}

// Exact literal only. '1', 'yes', 'TRUE ' or any other value disables.
export function isOwnershipIngestEnabled(env = process.env) {
return env[OWNERSHIP_FLAG_ENV] === 'true'
}

// Validated internal repository uuid allowlist. Malformed entries are dropped,
// whitespace is ignored, duplicates collapse, case is normalized.
export function canaryRepositoryIds(env = process.env) {
const raw = env[OWNERSHIP_ALLOWLIST_ENV]
if (typeof raw !== 'string' || raw.trim() === '') return new Set()
const ids = new Set()
for (const part of raw.split(',')) {
const id = part.trim()
if (UUID_RE.test(id)) ids.add(id.toLowerCase())
}
return ids
}

// The one predicate a runner calls: both conditions must hold.
export function ownershipIngestAllowed(repositoryId, env = process.env) {
if (!isOwnershipIngestEnabled(env)) return false
if (typeof repositoryId !== 'string' || !UUID_RE.test(repositoryId)) return false
return canaryRepositoryIds(env).has(repositoryId.toLowerCase())
}

// Assertive form for the top of a future ingestion boundary — a disabled gate
// throws before any network or DB work can start.
export function assertOwnershipIngestAllowed(repositoryId, env = process.env) {
if (!ownershipIngestAllowed(repositoryId, env)) {
throw new OwnershipDisabledError()
}
}
Loading
Loading