Skip to content

Commit a2adc5f

Browse files
Merge pull request #203 from CodeForPhilly/feat/person-evaluations-contract
feat: person-evaluations as a versioned gitsheets schema contract
2 parents e8643d1 + 7c34ea8 commit a2adc5f

16 files changed

Lines changed: 292 additions & 79 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"$id" = "https://codeforphilly.org/person-evaluations/v1"
2+
"$schema" = "http://json-schema.org/draft-07/schema#"
3+
description = 'One spam verdict about one person from one evaluator. Machine evaluators (heuristic, LLM, github-probe) write these in the private codeforphilly-spam-detection repo; staff votes are written to codeforphilly-data by the site with evaluator = "human-<voterSlug>". Records are interchangeable at prune time.'
4+
required = ["evaluatedAt", "evaluator", "flags", "personSlug", "verdict"]
5+
title = "Person evaluation"
6+
type = "object"
7+
8+
[properties.confidence]
9+
description = "0-1. LLM and probe evaluators; 1 on human votes; absent on heuristic records."
10+
maximum = 1
11+
minimum = 0
12+
type = "number"
13+
14+
[properties.evaluatedAt]
15+
description = "ISO 8601 UTC."
16+
format = "date-time"
17+
type = "string"
18+
19+
[properties.evaluator]
20+
description = "Model/run id (e.g. haiku-2026-05) or human-<voterSlug>."
21+
minLength = 1
22+
type = "string"
23+
24+
[properties.flags]
25+
description = "Short reason tags."
26+
type = "array"
27+
28+
[properties.flags.items]
29+
type = "string"
30+
31+
[properties.personSlug]
32+
description = "Slug of the evaluated person."
33+
minLength = 1
34+
type = "string"
35+
36+
[properties.reasoning]
37+
description = "Free-text justification. Optional on human votes."
38+
type = "string"
39+
40+
[properties.score]
41+
description = "Heuristic evaluators only: rule points, unbounded."
42+
type = "integer"
43+
44+
[properties.verdict]
45+
enum = ["spam", "legit", "uncertain"]
46+
type = "string"

‎apps/api/gitsheets/person-evaluations.toml‎

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,8 @@ root = "person-evaluations"
55
# One spam verdict per (person, evaluator). On this repo's branches the only
66
# writer is the site: staff votes with evaluator = "human-<voterSlug>" (see
77
# codeforphilly-ng specs/api/moderation.md). Machine evaluators (heuristic,
8-
# LLM) live in the private codeforphilly-spam-detection repo and share this
9-
# schema so records are interchangeable at prune time.
10-
[gitsheet.schema]
11-
required = ["evaluatedAt", "evaluator", "flags", "personSlug", "verdict"]
12-
type = "object"
13-
14-
[gitsheet.schema.properties.personSlug]
15-
type = "string"
16-
minLength = 1
17-
18-
[gitsheet.schema.properties.evaluator]
19-
type = "string"
20-
minLength = 1
21-
22-
[gitsheet.schema.properties.verdict]
23-
type = "string"
24-
enum = ["spam", "legit", "uncertain"]
25-
26-
# LLM evaluators only (scripts/evaluate-llm.ts ResultSchema: 0..1)
27-
[gitsheet.schema.properties.confidence]
28-
type = "number"
29-
minimum = 0
30-
maximum = 1
31-
32-
# Heuristic evaluators only (scripts/lib/heuristic.ts rule points, unbounded)
33-
[gitsheet.schema.properties.score]
34-
type = "integer"
35-
36-
[gitsheet.schema.properties.flags]
37-
type = "array"
38-
39-
[gitsheet.schema.properties.flags.items]
40-
type = "string"
41-
42-
# LLM evaluators only
43-
[gitsheet.schema.properties.reasoning]
44-
type = "string"
45-
46-
[gitsheet.schema.properties.evaluatedAt]
47-
type = "string"
48-
format = "date-time"
8+
# LLM) live in the private codeforphilly-spam-detection repo. The record
9+
# shape is the versioned contract below, which both repos and the site open
10+
# the sheet against; change it by adopting a new version (…/v2), never by
11+
# editing v1.
12+
implements = ["codeforphilly.org/person-evaluations/v1"]

‎apps/api/scripts/check-sheet-configs.ts‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
2-
* Verify the vendored sheet configs in `apps/api/gitsheets/` match the data
3-
* repo's `empty` branch, the source of truth for `.gitsheets/*.toml`
2+
* Verify the vendored sheet configs and schema contracts in
3+
* `apps/api/gitsheets/` match the data repo's `empty` branch, the source of
4+
* truth for everything under `.gitsheets/`
45
* (specs/behaviors/storage.md → "Why these path shapes").
56
*
67
* Tests seed every test data repo from the vendored copy, so drift between
@@ -11,12 +12,13 @@
1112
* Usage:
1213
* npm run -w apps/api check:sheet-configs [-- --remote=<url>] [-- --ref=<branch>]
1314
*
14-
* Exits 1 and prints each differing, missing, or extra config on drift.
15+
* Exits 1 when a vendored config differs from upstream or is missing there.
16+
* A config only upstream (a sheet the app doesn't open yet) is a warning.
1517
*/
1618
import { execFile } from 'node:child_process';
1719
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
1820
import { tmpdir } from 'node:os';
19-
import { dirname, join } from 'node:path';
21+
import { dirname, join, relative } from 'node:path';
2022
import { fileURLToPath } from 'node:url';
2123
import { promisify } from 'node:util';
2224

@@ -27,6 +29,17 @@ const DEFAULT_REMOTE = 'git@github.com:CodeForPhilly/codeforphilly-data.git';
2729
const DEFAULT_REF = 'empty';
2830
const VENDORED_DIR = join(dirname(fileURLToPath(import.meta.url)), '../gitsheets');
2931

32+
/** Every `.toml` file under `dir`, keyed by its path relative to `dir`. */
33+
async function readTomlTree(dir: string): Promise<Map<string, string>> {
34+
const out = new Map<string, string>();
35+
for (const entry of await readdir(dir, { recursive: true, withFileTypes: true })) {
36+
if (!entry.isFile() || !entry.name.endsWith('.toml')) continue;
37+
const abs = join(entry.parentPath, entry.name);
38+
out.set(relative(dir, abs), await readFile(abs, 'utf8'));
39+
}
40+
return out;
41+
}
42+
3043
function arg(name: string, fallback: string): string {
3144
const prefix = `--${name}=`;
3245
return process.argv.find((a) => a.startsWith(prefix))?.slice(prefix.length) ?? fallback;
@@ -42,34 +55,40 @@ async function main(): Promise<number> {
4255
await exec('git', ['init', '--bare', '-q', gitDir]);
4356
await git('fetch', '-q', '--depth=1', remote, ref);
4457

45-
const { stdout: listing } = await git('ls-tree', '--name-only', 'FETCH_HEAD', '.gitsheets/');
58+
const { stdout: listing } = await git('ls-tree', '-r', '--name-only', 'FETCH_HEAD', '.gitsheets/');
4659
const upstream = new Map<string, string>();
4760
for (const path of listing.split('\n').filter((p) => p.endsWith('.toml'))) {
4861
const { stdout } = await git('show', `FETCH_HEAD:${path}`);
4962
upstream.set(path.slice('.gitsheets/'.length), stdout);
5063
}
5164

52-
const vendored = new Map<string, string>();
53-
for (const name of (await readdir(VENDORED_DIR)).filter((f) => f.endsWith('.toml'))) {
54-
vendored.set(name, await readFile(join(VENDORED_DIR, name), 'utf8'));
55-
}
65+
const vendored = await readTomlTree(VENDORED_DIR);
5666

67+
// A sheet lands in the data repo before the app opens it, so a config
68+
// only upstream is expected mid-change: warn. Anything the app vendors
69+
// must match upstream exactly.
5770
const problems: string[] = [];
71+
const upstreamOnly: string[] = [];
5872
for (const [name, contents] of upstream) {
5973
const ours = vendored.get(name);
60-
if (ours === undefined) problems.push(`missing locally: ${name}`);
74+
if (ours === undefined) upstreamOnly.push(name);
6175
else if (ours !== contents) problems.push(`differs: ${name}`);
6276
}
6377
for (const name of vendored.keys()) {
6478
if (!upstream.has(name)) problems.push(`not in ${ref}: ${name}`);
6579
}
6680

81+
for (const name of upstreamOnly) {
82+
const msg = `${name} is in ${ref} but not vendored in apps/api/gitsheets/ (expected while a new sheet is landing)`;
83+
console.warn(process.env.GITHUB_ACTIONS ? `::warning title=Sheet config not vendored::${msg}` : `warning: ${msg}`);
84+
}
85+
6786
if (problems.length > 0) {
6887
console.error(`Sheet configs in apps/api/gitsheets/ drift from ${remote}#${ref}:`);
6988
for (const p of problems) console.error(` - ${p}`);
7089
return 1;
7190
}
72-
console.log(`${vendored.size} sheet configs match ${remote}#${ref}.`);
91+
console.log(`${vendored.size} files under .gitsheets/ match ${remote}#${ref}.`);
7392
return 0;
7493
} finally {
7594
await rm(gitDir, { recursive: true, force: true });

‎apps/api/scripts/scrub-data.ts‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@
3333

3434
import { createHash } from 'node:crypto';
3535
import { execFile } from 'node:child_process';
36-
import { mkdir, readdir, readFile, rm } from 'node:fs/promises';
37-
import { join, resolve } from 'node:path';
36+
import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
37+
import { tmpdir } from 'node:os';
38+
import { dirname, join, resolve } from 'node:path';
3839
import { promisify } from 'node:util';
3940

4041
import { faker } from '@faker-js/faker';
@@ -398,7 +399,10 @@ async function writeConfigRootCommit(
398399
targetPath: string,
399400
configs: ReadonlyArray<{ name: string; content: Buffer }>,
400401
): Promise<string> {
401-
const entries: string[] = [];
402+
// A throwaway index builds the nested `.gitsheets/` tree (configs plus
403+
// `contracts/<host>/<path>.toml`) without touching any real index.
404+
const indexFile = join(await mkdtemp(join(tmpdir(), 'cfp-scrub-index-')), 'index');
405+
const indexEnv = { ...process.env, GIT_INDEX_FILE: indexFile };
402406
for (const { name, content } of configs) {
403407
const hash = await new Promise<string>((resolvePromise, reject) => {
404408
const child = execFile(
@@ -409,24 +413,20 @@ async function writeConfigRootCommit(
409413
);
410414
child.stdin?.end(content);
411415
});
412-
entries.push(`100644 blob ${hash}\t${name}`);
416+
await exec('git', ['update-index', '--add', '--cacheinfo', `100644,${hash},.gitsheets/${name}`], {
417+
cwd: targetPath,
418+
env: indexEnv,
419+
});
413420
}
414-
const configTree = (await gitWithInput(targetPath, ['mktree'], entries.join('\n') + '\n')).trim();
415-
const rootTree = (await gitWithInput(targetPath, ['mktree'], `040000 tree ${configTree}\t.gitsheets\n`)).trim();
421+
const rootTree = (await exec('git', ['write-tree'], { cwd: targetPath, env: indexEnv })).stdout.trim();
422+
await rm(dirname(indexFile), { recursive: true, force: true });
416423
const { stdout } = await exec('git', ['commit-tree', rootTree, '-m', 'scrub-data: sheet configs'], {
417424
cwd: targetPath,
418425
env: { ...process.env, ...gitIdentityEnv(new Date().toISOString()) },
419426
});
420427
return stdout.trim();
421428
}
422429

423-
function gitWithInput(cwd: string, args: string[], input: string): Promise<string> {
424-
return new Promise((resolvePromise, reject) => {
425-
const child = execFile('git', args, { cwd }, (err, stdout) => (err ? reject(err) : resolvePromise(stdout)));
426-
child.stdin?.end(input);
427-
});
428-
}
429-
430430
function gitIdentityEnv(timestamp: string): Record<string, string> {
431431
return {
432432
GIT_AUTHOR_NAME: SNAPSHOT_AUTHOR.name,
@@ -536,10 +536,18 @@ export async function scrubRepo(opts: ScrubOptions): Promise<ScrubResult> {
536536
const targetGitDir = (await git('rev-parse', '--absolute-git-dir')).stdout.trim();
537537
const targetRepo = await openRepo({ gitDir: targetGitDir });
538538

539+
// Everything under the source's `.gitsheets/`: sheet configs plus the
540+
// vendored schema contracts (`contracts/…`) that configs may declare.
541+
const sourceRef = sourceHeadHash ?? 'HEAD';
542+
const configPaths = (
543+
await exec('git', ['ls-tree', '-r', '--name-only', sourceRef, '.gitsheets/'], { cwd: source })
544+
).stdout
545+
.split('\n')
546+
.filter((p) => p.length > 0);
539547
const configs = await Promise.all(
540-
Object.values(sheets).map(async (sheet) => ({
541-
name: `${sheet.name}.toml`,
542-
content: await streamToBuffer(await sourceRepo.readBlobStream(sourceHeadHash ?? 'HEAD', sheet.configPath)),
548+
configPaths.map(async (path) => ({
549+
name: path.slice('.gitsheets/'.length),
550+
content: await streamToBuffer(await sourceRepo.readBlobStream(sourceRef, path)),
543551
})),
544552
);
545553
const configRoot = await writeConfigRootCommit(target, configs);

‎apps/api/src/store/public.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { join } from 'node:path';
33

44
import { openRepo, openStore } from 'gitsheets';
55
import type { Repository, Store, StoreTx, ValidatorMap } from 'gitsheets';
6+
import { PERSON_EVALUATIONS_CONTRACT_V1 } from '@cfp/shared';
67
import {
78
BlogPostSchema,
89
HelpWantedInterestExpressionSchema,
@@ -83,5 +84,15 @@ export async function openPublicStore(
8384
repo.requireExplicitTransactions();
8485

8586
const store = await openStore(repo, { validators: PUBLIC_SHEET_SCHEMAS });
87+
88+
// person-evaluations is shared with the private spam pipeline through a
89+
// versioned schema contract (specs/behaviors/spam-exclusion.md). Refuse to
90+
// boot against a data repo whose sheet doesn't satisfy it: gitsheets
91+
// checks the declared contract's identity first, falling back to
92+
// validating every record.
93+
await repo.openSheet('person-evaluations', {
94+
contract: { schema: PERSON_EVALUATIONS_CONTRACT_V1 },
95+
});
96+
8697
return { store, repo };
8798
}

‎apps/api/tests/helpers/sheet-configs.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/**
2-
* The canonical `.gitsheets/*.toml` sheet configs, vendored from the data
3-
* repo's `empty` branch into `apps/api/gitsheets/`. Every test data repo is
2+
* The canonical `.gitsheets/` sheet configs and schema contracts, vendored
3+
* from the data repo's `empty` branch into `apps/api/gitsheets/`. Every test data repo is
44
* seeded from these so tests run against the same path templates production
55
* does. `npm run -w apps/api check:sheet-configs` verifies the vendored copy
66
* still matches the data repo.
77
*/
8-
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
8+
import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
99
import { dirname, join } from 'node:path';
1010
import { fileURLToPath } from 'node:url';
1111

@@ -25,10 +25,16 @@ export async function loadSheetConfigs(
2525
return Object.fromEntries(entries);
2626
}
2727

28-
/** Write the canonical configs into `<dir>/.gitsheets/`. */
28+
/**
29+
* Write the canonical configs into `<dir>/.gitsheets/`, plus the vendored
30+
* schema contracts (`contracts/`) that sheet configs may declare.
31+
*/
2932
export async function writeSheetConfigs(dir: string, only?: readonly string[]): Promise<void> {
3033
await mkdir(join(dir, '.gitsheets'), { recursive: true });
3134
for (const [name, contents] of Object.entries(await loadSheetConfigs(only))) {
3235
await writeFile(join(dir, '.gitsheets', `${name}.toml`), contents);
3336
}
37+
await cp(join(SHEET_CONFIG_DIR, 'contracts'), join(dir, '.gitsheets', 'contracts'), {
38+
recursive: true,
39+
});
3440
}

‎apps/api/tests/sheet-configs.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
import { readFile, readdir } from 'node:fs/promises';
1212
import { join } from 'node:path';
1313

14+
import { canonicalContractHash } from 'gitsheets';
1415
import { describe, expect, it } from 'vitest';
1516
import type { z } from 'zod';
1617

18+
import { PERSON_EVALUATIONS_CONTRACT_NAME, PERSON_EVALUATIONS_CONTRACT_V1 } from '@cfp/shared';
19+
1720
import { PUBLIC_SHEET_SCHEMAS } from '../src/store/public.js';
1821
import { SHEET_CONFIG_DIR } from './helpers/sheet-configs.js';
1922

@@ -64,4 +67,17 @@ describe('sheet configs', () => {
6467
expect(missing, `template fields ${sheet}'s records may lack`).toEqual([]);
6568
});
6669
}
70+
71+
it('vendors the person-evaluations contract byte-identical to the shared document', async () => {
72+
const vendored = await readFile(
73+
join(SHEET_CONFIG_DIR, 'contracts', `${PERSON_EVALUATIONS_CONTRACT_NAME}.toml`),
74+
'utf8',
75+
);
76+
expect(canonicalContractHash(vendored, { format: 'toml' })).toBe(
77+
canonicalContractHash(PERSON_EVALUATIONS_CONTRACT_V1),
78+
);
79+
const config = await readFile(join(SHEET_CONFIG_DIR, 'person-evaluations.toml'), 'utf8');
80+
expect(config).toContain(`implements = ["${PERSON_EVALUATIONS_CONTRACT_NAME}"]`);
81+
});
6782
});
83+

0 commit comments

Comments
 (0)