From be6bdafc0e29f8dcbbcab149eb7502dd4803a746 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 21:05:06 -0400 Subject: [PATCH 1/8] docs(specs): person-evaluations shape is a versioned gitsheets contract Co-Authored-By: Claude Opus 5.5 --- specs/behaviors/spam-exclusion.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/specs/behaviors/spam-exclusion.md b/specs/behaviors/spam-exclusion.md index caed1ee..02109aa 100644 --- a/specs/behaviors/spam-exclusion.md +++ b/specs/behaviors/spam-exclusion.md @@ -45,6 +45,17 @@ Each record: | `reasoning` | free-text justification (optional on human votes) | | `evaluatedAt` | ISO 8601 UTC | +The record shape is the versioned schema contract +**`codeforphilly.org/person-evaluations/v1`** (gitsheets schema contracts). Its +canonical document lives in the data repo's `empty` branch at +`.gitsheets/contracts/codeforphilly.org/person-evaluations/v1.toml`, and the +`person-evaluations` sheet declares `implements = ["codeforphilly.org/person-evaluations/v1"]`, +so every write on every branch is validated against it. Both consumers hold a +copy of the same document and open the sheet against it: the API at boot (a +mismatch fails the boot), and the spam-detection pipeline in the private repo. +A change to the record shape is a new contract version (`…/v2`), never an edit +to `v1`. + Machine evaluations stay in the private repo; the runtime never loads them. The pipeline reads machine records from its own repo and human votes from `published`, aggregates, and applies the result to `published`. From d74d3f0e85a03844be039ebdd3912011ca03248f Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 21:13:01 -0400 Subject: [PATCH 2/8] feat(shared): person-evaluations v1 schema contract document The record shape shared by the site and the private spam pipeline, as a gitsheets schema contract (codeforphilly.org/person-evaluations/v1). Same constraints as the data repo's sheet-local schema it replaces. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/contracts.ts | 31 +++++++++++++++++++++++++++++++ packages/shared/src/index.ts | 1 + 2 files changed, 32 insertions(+) create mode 100644 packages/shared/src/contracts.ts diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts new file mode 100644 index 0000000..517a042 --- /dev/null +++ b/packages/shared/src/contracts.ts @@ -0,0 +1,31 @@ +/** + * Versioned gitsheets schema contracts this project's sheets implement — + * specs/behaviors/spam-exclusion.md. + * + * The canonical (TOML) copy lives in codeforphilly-data's `empty` branch at + * `.gitsheets/contracts/.toml`; consumers open the sheet against the + * document below, and a test checks the two hash identically. Published + * versions are immutable: a shape change is a new name (`…/v2`). + */ + +export const PERSON_EVALUATIONS_CONTRACT_NAME = 'codeforphilly.org/person-evaluations/v1'; + +export const PERSON_EVALUATIONS_CONTRACT_V1 = +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://codeforphilly.org/person-evaluations/v1", + "title": "Person evaluation", + "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-\". Records are interchangeable at prune time.", + "type": "object", + "required": ["evaluatedAt", "evaluator", "flags", "personSlug", "verdict"], + "properties": { + "personSlug": { "type": "string", "minLength": 1, "description": "Slug of the evaluated person." }, + "evaluator": { "type": "string", "minLength": 1, "description": "Model/run id (e.g. haiku-2026-05) or human-." }, + "verdict": { "type": "string", "enum": ["spam", "legit", "uncertain"] }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "0-1. LLM and probe evaluators; 1 on human votes; absent on heuristic records." }, + "score": { "type": "integer", "description": "Heuristic evaluators only: rule points, unbounded." }, + "flags": { "type": "array", "items": { "type": "string" }, "description": "Short reason tags." }, + "reasoning": { "type": "string", "description": "Free-text justification. Optional on human votes." }, + "evaluatedAt": { "type": "string", "format": "date-time", "description": "ISO 8601 UTC." } + } +} as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 33b6978..95849a3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './schemas/index.js'; export * from './markdown.js'; export * from './slug.js'; +export * from './contracts.js'; From ddef7dc067ca99e33fb869a35955afbc40f05536 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 21:13:01 -0400 Subject: [PATCH 3/8] feat(api): open person-evaluations against its schema contract at boot The data repo's person-evaluations sheet now declares the v1 contract; the API verifies it at boot (identity first, else every record) and refuses to start on a mismatch. Vendors the contract next to the sheet configs, seeds test repos with it, and extends the CI drift check to everything under .gitsheets/. Co-Authored-By: Claude Opus 5.5 --- .../person-evaluations/v1.toml | 46 ++++++++++++++++++ apps/api/gitsheets/person-evaluations.toml | 46 ++---------------- apps/api/scripts/check-sheet-configs.ts | 27 +++++++---- apps/api/src/store/public.ts | 11 +++++ apps/api/tests/helpers/sheet-configs.ts | 14 ++++-- apps/api/tests/sheet-configs.test.ts | 16 +++++++ apps/api/tests/store.test.ts | 47 +++++++++++++++++++ 7 files changed, 153 insertions(+), 54 deletions(-) create mode 100644 apps/api/gitsheets/contracts/codeforphilly.org/person-evaluations/v1.toml diff --git a/apps/api/gitsheets/contracts/codeforphilly.org/person-evaluations/v1.toml b/apps/api/gitsheets/contracts/codeforphilly.org/person-evaluations/v1.toml new file mode 100644 index 0000000..e4c8a06 --- /dev/null +++ b/apps/api/gitsheets/contracts/codeforphilly.org/person-evaluations/v1.toml @@ -0,0 +1,46 @@ +"$id" = "https://codeforphilly.org/person-evaluations/v1" +"$schema" = "http://json-schema.org/draft-07/schema#" +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-". Records are interchangeable at prune time.' +required = ["evaluatedAt", "evaluator", "flags", "personSlug", "verdict"] +title = "Person evaluation" +type = "object" + +[properties.confidence] +description = "0-1. LLM and probe evaluators; 1 on human votes; absent on heuristic records." +maximum = 1 +minimum = 0 +type = "number" + +[properties.evaluatedAt] +description = "ISO 8601 UTC." +format = "date-time" +type = "string" + +[properties.evaluator] +description = "Model/run id (e.g. haiku-2026-05) or human-." +minLength = 1 +type = "string" + +[properties.flags] +description = "Short reason tags." +type = "array" + +[properties.flags.items] +type = "string" + +[properties.personSlug] +description = "Slug of the evaluated person." +minLength = 1 +type = "string" + +[properties.reasoning] +description = "Free-text justification. Optional on human votes." +type = "string" + +[properties.score] +description = "Heuristic evaluators only: rule points, unbounded." +type = "integer" + +[properties.verdict] +enum = ["spam", "legit", "uncertain"] +type = "string" diff --git a/apps/api/gitsheets/person-evaluations.toml b/apps/api/gitsheets/person-evaluations.toml index 1c45293..a073bc5 100644 --- a/apps/api/gitsheets/person-evaluations.toml +++ b/apps/api/gitsheets/person-evaluations.toml @@ -5,44 +5,8 @@ root = "person-evaluations" # One spam verdict per (person, evaluator). On this repo's branches the only # writer is the site: staff votes with evaluator = "human-" (see # codeforphilly-ng specs/api/moderation.md). Machine evaluators (heuristic, -# LLM) live in the private codeforphilly-spam-detection repo and share this -# schema so records are interchangeable at prune time. -[gitsheet.schema] -required = ["evaluatedAt", "evaluator", "flags", "personSlug", "verdict"] -type = "object" - -[gitsheet.schema.properties.personSlug] -type = "string" -minLength = 1 - -[gitsheet.schema.properties.evaluator] -type = "string" -minLength = 1 - -[gitsheet.schema.properties.verdict] -type = "string" -enum = ["spam", "legit", "uncertain"] - -# LLM evaluators only (scripts/evaluate-llm.ts ResultSchema: 0..1) -[gitsheet.schema.properties.confidence] -type = "number" -minimum = 0 -maximum = 1 - -# Heuristic evaluators only (scripts/lib/heuristic.ts rule points, unbounded) -[gitsheet.schema.properties.score] -type = "integer" - -[gitsheet.schema.properties.flags] -type = "array" - -[gitsheet.schema.properties.flags.items] -type = "string" - -# LLM evaluators only -[gitsheet.schema.properties.reasoning] -type = "string" - -[gitsheet.schema.properties.evaluatedAt] -type = "string" -format = "date-time" +# LLM) live in the private codeforphilly-spam-detection repo. The record +# shape is the versioned contract below, which both repos and the site open +# the sheet against; change it by adopting a new version (…/v2), never by +# editing v1. +implements = ["codeforphilly.org/person-evaluations/v1"] diff --git a/apps/api/scripts/check-sheet-configs.ts b/apps/api/scripts/check-sheet-configs.ts index 91f771f..ca41921 100644 --- a/apps/api/scripts/check-sheet-configs.ts +++ b/apps/api/scripts/check-sheet-configs.ts @@ -1,6 +1,7 @@ /** - * Verify the vendored sheet configs in `apps/api/gitsheets/` match the data - * repo's `empty` branch, the source of truth for `.gitsheets/*.toml` + * Verify the vendored sheet configs and schema contracts in + * `apps/api/gitsheets/` match the data repo's `empty` branch, the source of + * truth for everything under `.gitsheets/` * (specs/behaviors/storage.md → "Why these path shapes"). * * Tests seed every test data repo from the vendored copy, so drift between @@ -16,7 +17,7 @@ import { execFile } from 'node:child_process'; import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -27,6 +28,17 @@ const DEFAULT_REMOTE = 'git@github.com:CodeForPhilly/codeforphilly-data.git'; const DEFAULT_REF = 'empty'; const VENDORED_DIR = join(dirname(fileURLToPath(import.meta.url)), '../gitsheets'); +/** Every `.toml` file under `dir`, keyed by its path relative to `dir`. */ +async function readTomlTree(dir: string): Promise> { + const out = new Map(); + for (const entry of await readdir(dir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.toml')) continue; + const abs = join(entry.parentPath, entry.name); + out.set(relative(dir, abs), await readFile(abs, 'utf8')); + } + return out; +} + function arg(name: string, fallback: string): string { const prefix = `--${name}=`; return process.argv.find((a) => a.startsWith(prefix))?.slice(prefix.length) ?? fallback; @@ -42,17 +54,14 @@ async function main(): Promise { await exec('git', ['init', '--bare', '-q', gitDir]); await git('fetch', '-q', '--depth=1', remote, ref); - const { stdout: listing } = await git('ls-tree', '--name-only', 'FETCH_HEAD', '.gitsheets/'); + const { stdout: listing } = await git('ls-tree', '-r', '--name-only', 'FETCH_HEAD', '.gitsheets/'); const upstream = new Map(); for (const path of listing.split('\n').filter((p) => p.endsWith('.toml'))) { const { stdout } = await git('show', `FETCH_HEAD:${path}`); upstream.set(path.slice('.gitsheets/'.length), stdout); } - const vendored = new Map(); - for (const name of (await readdir(VENDORED_DIR)).filter((f) => f.endsWith('.toml'))) { - vendored.set(name, await readFile(join(VENDORED_DIR, name), 'utf8')); - } + const vendored = await readTomlTree(VENDORED_DIR); const problems: string[] = []; for (const [name, contents] of upstream) { @@ -69,7 +78,7 @@ async function main(): Promise { for (const p of problems) console.error(` - ${p}`); return 1; } - console.log(`${vendored.size} sheet configs match ${remote}#${ref}.`); + console.log(`${vendored.size} files under .gitsheets/ match ${remote}#${ref}.`); return 0; } finally { await rm(gitDir, { recursive: true, force: true }); diff --git a/apps/api/src/store/public.ts b/apps/api/src/store/public.ts index ad98253..2bd4a14 100644 --- a/apps/api/src/store/public.ts +++ b/apps/api/src/store/public.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { openRepo, openStore } from 'gitsheets'; import type { Repository, Store, StoreTx, ValidatorMap } from 'gitsheets'; +import { PERSON_EVALUATIONS_CONTRACT_V1 } from '@cfp/shared'; import { BlogPostSchema, HelpWantedInterestExpressionSchema, @@ -83,5 +84,15 @@ export async function openPublicStore( repo.requireExplicitTransactions(); const store = await openStore(repo, { validators: PUBLIC_SHEET_SCHEMAS }); + + // person-evaluations is shared with the private spam pipeline through a + // versioned schema contract (specs/behaviors/spam-exclusion.md). Refuse to + // boot against a data repo whose sheet doesn't satisfy it: gitsheets + // checks the declared contract's identity first, falling back to + // validating every record. + await repo.openSheet('person-evaluations', { + contract: { schema: PERSON_EVALUATIONS_CONTRACT_V1 }, + }); + return { store, repo }; } diff --git a/apps/api/tests/helpers/sheet-configs.ts b/apps/api/tests/helpers/sheet-configs.ts index 512d957..a0a64a8 100644 --- a/apps/api/tests/helpers/sheet-configs.ts +++ b/apps/api/tests/helpers/sheet-configs.ts @@ -1,11 +1,11 @@ /** - * The canonical `.gitsheets/*.toml` sheet configs, vendored from the data - * repo's `empty` branch into `apps/api/gitsheets/`. Every test data repo is + * The canonical `.gitsheets/` sheet configs and schema contracts, vendored + * from the data repo's `empty` branch into `apps/api/gitsheets/`. Every test data repo is * seeded from these so tests run against the same path templates production * does. `npm run -w apps/api check:sheet-configs` verifies the vendored copy * still matches the data repo. */ -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,10 +25,16 @@ export async function loadSheetConfigs( return Object.fromEntries(entries); } -/** Write the canonical configs into `/.gitsheets/`. */ +/** + * Write the canonical configs into `/.gitsheets/`, plus the vendored + * schema contracts (`contracts/`) that sheet configs may declare. + */ export async function writeSheetConfigs(dir: string, only?: readonly string[]): Promise { await mkdir(join(dir, '.gitsheets'), { recursive: true }); for (const [name, contents] of Object.entries(await loadSheetConfigs(only))) { await writeFile(join(dir, '.gitsheets', `${name}.toml`), contents); } + await cp(join(SHEET_CONFIG_DIR, 'contracts'), join(dir, '.gitsheets', 'contracts'), { + recursive: true, + }); } diff --git a/apps/api/tests/sheet-configs.test.ts b/apps/api/tests/sheet-configs.test.ts index bc0b83e..6be6082 100644 --- a/apps/api/tests/sheet-configs.test.ts +++ b/apps/api/tests/sheet-configs.test.ts @@ -11,9 +11,12 @@ import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; +import { canonicalContractHash } from 'gitsheets'; import { describe, expect, it } from 'vitest'; import type { z } from 'zod'; +import { PERSON_EVALUATIONS_CONTRACT_NAME, PERSON_EVALUATIONS_CONTRACT_V1 } from '@cfp/shared'; + import { PUBLIC_SHEET_SCHEMAS } from '../src/store/public.js'; import { SHEET_CONFIG_DIR } from './helpers/sheet-configs.js'; @@ -64,4 +67,17 @@ describe('sheet configs', () => { expect(missing, `template fields ${sheet}'s records may lack`).toEqual([]); }); } + + it('vendors the person-evaluations contract byte-identical to the shared document', async () => { + const vendored = await readFile( + join(SHEET_CONFIG_DIR, 'contracts', `${PERSON_EVALUATIONS_CONTRACT_NAME}.toml`), + 'utf8', + ); + expect(canonicalContractHash(vendored, { format: 'toml' })).toBe( + canonicalContractHash(PERSON_EVALUATIONS_CONTRACT_V1), + ); + const config = await readFile(join(SHEET_CONFIG_DIR, 'person-evaluations.toml'), 'utf8'); + expect(config).toContain(`implements = ["${PERSON_EVALUATIONS_CONTRACT_NAME}"]`); + }); }); + diff --git a/apps/api/tests/store.test.ts b/apps/api/tests/store.test.ts index 34e08f4..cf1cc45 100644 --- a/apps/api/tests/store.test.ts +++ b/apps/api/tests/store.test.ts @@ -20,6 +20,8 @@ import { Store } from '../src/store/store.js'; import { openPublicStore } from '../src/store/public.js'; import { createTestRepo } from './helpers/test-repo.js'; import { createFullDataRepo } from './helpers/test-full-repo.js'; +import { seedRawBlob, seedRawToml } from './helpers/seed-fixtures.js'; +import { PERSON_EVALUATIONS_CONTRACT_NAME } from '@cfp/shared'; const exec = promisify(execFile); @@ -164,6 +166,51 @@ describe('public store (gitsheets)', () => { // Tests: Store (dual-store coordination) // ------------------------------------------------------------------------- +describe('person-evaluations contract at boot', () => { + it('refuses to open a data repo whose evaluations break the contract', async () => { + const repo = await createFullDataRepo(); + try { + // A namesake contract with different bytes misses rung 1 (identity), + // so gitsheets validates every record (rung 2) — and this one has a + // verdict the contract doesn't allow. + const contractPath = `.gitsheets/contracts/${PERSON_EVALUATIONS_CONTRACT_NAME}.toml`; + const vendored = (await exec('git', ['show', `HEAD:${contractPath}`], { cwd: repo.path })) + .stdout; + await seedRawBlob( + repo.path, + contractPath, + Buffer.from(vendored.replace('title = "Person evaluation"', 'title = "Drifted"')), + 'test: drift the vendored contract', + ); + await seedRawToml( + repo.path, + 'person-evaluations/jane/human-boss.toml', + [ + `personSlug = "jane"`, + `evaluator = "human-boss"`, + `verdict = "maybe"`, + `flags = []`, + `evaluatedAt = "2026-05-20T00:00:00Z"`, + ].join('\n'), + 'test: an evaluation outside the contract', + ); + + await expect(openPublicStore(repo.path)).rejects.toThrow(/contract/i); + } finally { + await repo.cleanup(); + } + }); + + it('opens a data repo that declares the identical contract', async () => { + const repo = await createFullDataRepo(); + try { + await expect(openPublicStore(repo.path)).resolves.toBeDefined(); + } finally { + await repo.cleanup(); + } + }); +}); + describe('Store (dual-store coordination)', () => { let privateStoreDir: string; let privateCleanup: () => Promise; From 76059090c46b150972b3fad12551ef40cc19156c Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 21:14:26 -0400 Subject: [PATCH 4/8] chore(plans): person-evaluations-contract in progress (PR #203) Co-Authored-By: Claude Opus 5.5 --- plans/person-evaluations-contract.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/plans/person-evaluations-contract.md b/plans/person-evaluations-contract.md index c53c8ab..4871e58 100644 --- a/plans/person-evaluations-contract.md +++ b/plans/person-evaluations-contract.md @@ -1,8 +1,9 @@ --- -status: planned +status: in-progress depends: [gitsheets-freshness] specs: - specs/behaviors/spam-exclusion.md +pr: 203 --- # Plan: person-evaluations as a gitsheets schema contract @@ -44,10 +45,10 @@ path templates, so they would not have caught the slug-history bug. - [ ] Contract present on all data-repo branches; `git sheet contracts verify` clean -- [ ] cfp boot fails with a clear error against a deliberately drifted +- [x] cfp boot fails with a clear error against a deliberately drifted config (test) - [ ] Spam pipeline opens against the contract -- [ ] `type-check` + `lint` + full api suite green +- [x] `type-check` + `lint` + full api suite green ## Risks / unknowns @@ -55,4 +56,18 @@ path templates, so they would not have caught the slug-history bug. ## Notes +- In progress until the data-repo commits are pushed. They're prepared + (contract plus `implements` on `empty`, `fixture`, `legacy-import` and + `published`), and `gitsheets contracts verify` passes on `published` (27 + human votes). +- The pipeline side is the sheet declaration + (CodeForPhilly/codeforphilly-spam-detection#1; `contracts verify` passes + over every machine record). The pipeline never reads the data repo's + human votes; cfp's `prune-spam` does. So the consumer-side `openSheet({ + contract })` belongs in `prune-spam` once the `gitsheets-scripts` + refactor lands, not in the pipeline. +- `legacy-import` had no `person-evaluations` config before; it gains one. +- The contract keeps the old sheet-local schema's exact constraints, adding + only `$id`, `title` and `description` annotations. + ## Follow-ups From ac5a013da6d924dbe276f19c9c0a0022659d90bd Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 21:15:41 -0400 Subject: [PATCH 5/8] chore(plans): link upstream gitsheets issues in awaits gitsheets#301 (repo.sync) and #302 (rename moves attachments). Co-Authored-By: Claude Opus 5.5 --- plans/gitsheets-sync.md | 4 ++-- plans/slug-rename-attachments.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plans/gitsheets-sync.md b/plans/gitsheets-sync.md index 5b84133..c562f7b 100644 --- a/plans/gitsheets-sync.md +++ b/plans/gitsheets-sync.md @@ -4,7 +4,7 @@ depends: [gitsheets-freshness] specs: - specs/behaviors/storage.md awaits: - - "JarvusInnovations/gitsheets — opt-in repo.sync() fetch/replay primitive + repo.isAncestor (issue TBD) — replaces store/reconcile.ts" + - "https://github.com/JarvusInnovations/gitsheets/issues/301 — opt-in repo.sync() fetch/replay primitive + repo.isAncestor — replaces store/reconcile.ts" --- # Plan: replace hand-rolled reconcile with upstream `repo.sync()` @@ -49,7 +49,7 @@ spec already defines, and map upstream outcomes onto it. ## Validation -- [ ] Upstream issue filed and linked here +- [x] Upstream issue filed and linked here (gitsheets#301) - [ ] The existing reconcile test matrix (ff, replay, escape-hatch, in-sync) passes unchanged against `repo.sync()` - [ ] No `merge-tree`, `commit-tree`, `update-ref` or `merge-base` left in diff --git a/plans/slug-rename-attachments.md b/plans/slug-rename-attachments.md index e1d7418..47110f3 100644 --- a/plans/slug-rename-attachments.md +++ b/plans/slug-rename-attachments.md @@ -4,7 +4,7 @@ depends: [slug-history-config] specs: - specs/behaviors/slug-handles.md awaits: - - "JarvusInnovations/gitsheets — rename-on-upsert moves the record's attachment folder, or sheet.move() (issue TBD)" + - "https://github.com/JarvusInnovations/gitsheets/issues/302 — rename-on-upsert moves the record's attachment folder, or sheet.move()" --- # Plan: slug renames keep attachments From e2bd7b8f99f45eb44a533c3dbb1ff1f3004314c8 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 22:42:22 -0400 Subject: [PATCH 6/8] fix(ci): drift check warns on configs that are only in the data repo A new sheet lands in the data repo's empty branch before the app opens it, so failing on upstream-only configs broke CI for everyone the moment the events sheets landed. Vendored files must still match exactly; upstream-only ones warn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PxDwrzedwQoijkwbqSGKj --- apps/api/scripts/check-sheet-configs.ts | 14 ++++++++++++-- specs/behaviors/storage.md | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/api/scripts/check-sheet-configs.ts b/apps/api/scripts/check-sheet-configs.ts index ca41921..d6f263a 100644 --- a/apps/api/scripts/check-sheet-configs.ts +++ b/apps/api/scripts/check-sheet-configs.ts @@ -12,7 +12,8 @@ * Usage: * npm run -w apps/api check:sheet-configs [-- --remote=] [-- --ref=] * - * Exits 1 and prints each differing, missing, or extra config on drift. + * Exits 1 when a vendored config differs from upstream or is missing there. + * A config only upstream (a sheet the app doesn't open yet) is a warning. */ import { execFile } from 'node:child_process'; import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; @@ -63,16 +64,25 @@ async function main(): Promise { const vendored = await readTomlTree(VENDORED_DIR); + // A sheet lands in the data repo before the app opens it, so a config + // only upstream is expected mid-change: warn. Anything the app vendors + // must match upstream exactly. const problems: string[] = []; + const upstreamOnly: string[] = []; for (const [name, contents] of upstream) { const ours = vendored.get(name); - if (ours === undefined) problems.push(`missing locally: ${name}`); + if (ours === undefined) upstreamOnly.push(name); else if (ours !== contents) problems.push(`differs: ${name}`); } for (const name of vendored.keys()) { if (!upstream.has(name)) problems.push(`not in ${ref}: ${name}`); } + for (const name of upstreamOnly) { + const msg = `${name} is in ${ref} but not vendored in apps/api/gitsheets/ (expected while a new sheet is landing)`; + console.warn(process.env.GITHUB_ACTIONS ? `::warning title=Sheet config not vendored::${msg}` : `warning: ${msg}`); + } + if (problems.length > 0) { console.error(`Sheet configs in apps/api/gitsheets/ drift from ${remote}#${ref}:`); for (const p of problems) console.error(` - ${p}`); diff --git a/specs/behaviors/storage.md b/specs/behaviors/storage.md index 83822f9..ed05215 100644 --- a/specs/behaviors/storage.md +++ b/specs/behaviors/storage.md @@ -133,7 +133,7 @@ The path template is the only "index" gitsheets provides natively. Choose it to Every `${field}` in a path template must be a field every record of that sheet carries. A template naming a field the record lacks makes every upsert throw, aborting the whole transaction. Some fields exist only to render the path (`projectSlug`, `personSlug` on child records) and are added by the write services. -The data repo's `empty` branch is the source of truth for the `.gitsheets/*.toml` configs. The API vendors a copy at `apps/api/gitsheets/`; tests seed every test data repo from it, and CI fails when the copy and `empty` diverge (given a deploy key that can read the private data repo; without one the check is skipped with a warning). +The data repo's `empty` branch is the source of truth for the `.gitsheets/*.toml` configs. The API vendors a copy at `apps/api/gitsheets/`; tests seed every test data repo from it, and CI fails when a vendored file differs from `empty` or is missing there (a config only in `empty`, for a sheet the API doesn't open yet, is a warning, since sheets land in the data repo first) (given a deploy key that can read the private data repo; without one the check is skipped with a warning). ### Record format From e66685c1068d47afa8cf2fdffa8c55fc6b8955ad Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 22:42:32 -0400 Subject: [PATCH 7/8] chore(plans): add drift-check-upstream-only (PR #206) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PxDwrzedwQoijkwbqSGKj --- plans/drift-check-upstream-only.md | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 plans/drift-check-upstream-only.md diff --git a/plans/drift-check-upstream-only.md b/plans/drift-check-upstream-only.md new file mode 100644 index 0000000..26a9340 --- /dev/null +++ b/plans/drift-check-upstream-only.md @@ -0,0 +1,38 @@ +--- +status: done +depends: [slug-history-config] +specs: + - specs/behaviors/storage.md +pr: 203 +--- + +# Plan: drift check tolerates configs that are only in the data repo + +## Scope + +The sheet-config drift check (from [`slug-history-config`](slug-history-config.md)) +failed whenever `codeforphilly-data#empty` had a config the app doesn't +vendor. Sheets land in the data repo before the app opens them, so the +`events` / `event-rsvps` configs turned CI red everywhere. Upstream-only +files now warn; vendored files must still match exactly. + +## Implements + +- [behaviors/storage.md](../specs/behaviors/storage.md): the config-ownership + paragraph says what fails and what warns. + +## Validation + +- [x] Against the live `empty` (with the events configs): exits 0 with two + warnings +- [x] A vendored config that differs still fails (the pre-push state of + #199 was exactly that; folded into #203 from #206 so both land with the contract) +- [x] `type-check` + `lint` clean + +## Notes + +None. + +## Follow-ups + +None. From 7c34ea84773997840b0d0f2a911183c6f490bd04 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 13 Oct 2026 22:58:40 -0400 Subject: [PATCH 8/8] fix(api): scrub-data copies vendored contracts into the snapshot The config root commit carried only top-level sheet configs, so a sheet declaring `implements` failed every write with contract_missing. Copy everything under .gitsheets/, nested contracts included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PxDwrzedwQoijkwbqSGKj --- apps/api/scripts/scrub-data.ts | 40 ++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/api/scripts/scrub-data.ts b/apps/api/scripts/scrub-data.ts index 2a8d4ce..37660df 100644 --- a/apps/api/scripts/scrub-data.ts +++ b/apps/api/scripts/scrub-data.ts @@ -33,8 +33,9 @@ import { createHash } from 'node:crypto'; import { execFile } from 'node:child_process'; -import { mkdir, readdir, readFile, rm } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { faker } from '@faker-js/faker'; @@ -398,7 +399,10 @@ async function writeConfigRootCommit( targetPath: string, configs: ReadonlyArray<{ name: string; content: Buffer }>, ): Promise { - const entries: string[] = []; + // A throwaway index builds the nested `.gitsheets/` tree (configs plus + // `contracts//.toml`) without touching any real index. + const indexFile = join(await mkdtemp(join(tmpdir(), 'cfp-scrub-index-')), 'index'); + const indexEnv = { ...process.env, GIT_INDEX_FILE: indexFile }; for (const { name, content } of configs) { const hash = await new Promise((resolvePromise, reject) => { const child = execFile( @@ -409,10 +413,13 @@ async function writeConfigRootCommit( ); child.stdin?.end(content); }); - entries.push(`100644 blob ${hash}\t${name}`); + await exec('git', ['update-index', '--add', '--cacheinfo', `100644,${hash},.gitsheets/${name}`], { + cwd: targetPath, + env: indexEnv, + }); } - const configTree = (await gitWithInput(targetPath, ['mktree'], entries.join('\n') + '\n')).trim(); - const rootTree = (await gitWithInput(targetPath, ['mktree'], `040000 tree ${configTree}\t.gitsheets\n`)).trim(); + const rootTree = (await exec('git', ['write-tree'], { cwd: targetPath, env: indexEnv })).stdout.trim(); + await rm(dirname(indexFile), { recursive: true, force: true }); const { stdout } = await exec('git', ['commit-tree', rootTree, '-m', 'scrub-data: sheet configs'], { cwd: targetPath, env: { ...process.env, ...gitIdentityEnv(new Date().toISOString()) }, @@ -420,13 +427,6 @@ async function writeConfigRootCommit( return stdout.trim(); } -function gitWithInput(cwd: string, args: string[], input: string): Promise { - return new Promise((resolvePromise, reject) => { - const child = execFile('git', args, { cwd }, (err, stdout) => (err ? reject(err) : resolvePromise(stdout))); - child.stdin?.end(input); - }); -} - function gitIdentityEnv(timestamp: string): Record { return { GIT_AUTHOR_NAME: SNAPSHOT_AUTHOR.name, @@ -536,10 +536,18 @@ export async function scrubRepo(opts: ScrubOptions): Promise { const targetGitDir = (await git('rev-parse', '--absolute-git-dir')).stdout.trim(); const targetRepo = await openRepo({ gitDir: targetGitDir }); + // Everything under the source's `.gitsheets/`: sheet configs plus the + // vendored schema contracts (`contracts/…`) that configs may declare. + const sourceRef = sourceHeadHash ?? 'HEAD'; + const configPaths = ( + await exec('git', ['ls-tree', '-r', '--name-only', sourceRef, '.gitsheets/'], { cwd: source }) + ).stdout + .split('\n') + .filter((p) => p.length > 0); const configs = await Promise.all( - Object.values(sheets).map(async (sheet) => ({ - name: `${sheet.name}.toml`, - content: await streamToBuffer(await sourceRepo.readBlobStream(sourceHeadHash ?? 'HEAD', sheet.configPath)), + configPaths.map(async (path) => ({ + name: path.slice('.gitsheets/'.length), + content: await streamToBuffer(await sourceRepo.readBlobStream(sourceRef, path)), })), ); const configRoot = await writeConfigRootCommit(target, configs);