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
1 change: 1 addition & 0 deletions changelog.d/425-evolution-dataset.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`npm run evolution:export` now writes one dataset with a stable identity instead of a bare row array: a `ds_` id, the source window, the repositories, the row count, a sha256 `contentHash` over the canonical manifest and rows, a train/holdout split that keeps every repository on one side, and the holdout entries a promotion must not repeat. Every exported string is passed through the platform redactor, and only repositories an admin opted in with `npm run evolution:consent -- --repo owner/name --on` appear at all; consent is off by default and every change is audited. `--report` prints the manifest's own split counts, and the exporter still opens the database read-only and never writes to it.
35 changes: 32 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -925,9 +925,38 @@ held-out evidence, and a human-approved decision. AgentDescent is an offline
engine behind this ledger; it never runs a user repository, never receives a
GitHub token and cannot change the evaluator, CI or merge gate. See
[decisions/0003](decisions/0003-agentdescent-evolution.md). The read-only
`scripts/evolution-shadow.mjs` exporter turns recent jobs into a redacted
dataset, and `--report` prints the baseline plus a time-ordered train/holdout
split; it does not write to the database.
`scripts/evolution-shadow.ts` exporter turns recent jobs into one dataset with a
manifest. The manifest records the `ds_` id, the selection (`days` and the
applied `limit`), the repositories, `rowCount`, the effective
`trainRows`/`holdoutRows`, the `split.train`/`split.holdout` job-id arrays, the
holdout's protected entries, and a `window` that is provenance only: nominal
`from`/`to` bounds plus `oldestRowAt`/`newestRowAt`, the real coverage of the
rows that were written. The bounds are anchored on `newestRowAt` unless the
caller passes an explicit `now`, while the exporter's SQL bound is the
wall-clock `now - days`; they are the requested extent, not a claim that the
rows reach it. `--limit` truncates to the newest N inside `--days`, so
`oldestRowAt`/`newestRowAt`, not the window bound, say how far the export
actually reaches.

`contentHash` is sha256 over the selection, the split and the canonical, redacted
rows; the window, the id and the hash itself are outside the hashed object. `id`
is `ds_` plus the first 16 hex characters of that hash, so the same rows and
parameters produce the same hash twice. `--verify <file>` recomputes the split
from the records, the hash and the id, and refuses with `SPLIT_MISMATCH`,
`MANIFEST_MISMATCH`, `CONTENT_HASH_MISMATCH`, `DATASET_ID_MISMATCH` or
`MALFORMED_DATASET`; a moved job id fails even if someone recomputes the hash.

The split is repository-disjoint first: the newest 20% of rows by time only
chooses which repositories are held out, and every row of a repository named in
that tail moves to the holdout — so the holdout share can exceed 20%, and a
holdout row can be older than a train row. `#426`–`#432` read the split from the
manifest rather than assuming its size. The exporter refuses with
`NO_TRAINING_ROWS` and writes nothing when the split would hold out every row,
for example with a single consented repository. Every exported string passes
through `redact()`, and the exporter reads only repositories that opted in
through the audited `evolution.consent` command; consent is off by default.
`--report` prints the baseline and the manifest's own split counts; the export
does not write to the database.

The ledger's shape is the table that already existed, extended rather than
replaced: `artifact_versions` gains `kind`, `scope`, `repo_id`, `parent_id` (the
Expand Down
15 changes: 11 additions & 4 deletions docs/decisions/0003-agentdescent-evolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,17 @@ frozen path is refused before evaluation, with `evolution.candidate.refused` in
the audit log.

`job_events` stores the durable timeline that makes a job replayable. The
`scripts/evolution-shadow.mjs` export is read-only and excludes tokens,
callback secrets and API keys. Run `npm run evolution:report` (or pass `--report`) to print the current
baseline (success/merge rate, evidence completeness, median duration) and a
time-ordered train/holdout split before any candidate is generated.
`scripts/evolution-shadow.ts` export is read-only and excludes tokens,
callback secrets and API keys: every exported string passes through `redact()`,
and only repositories an admin opted in through the audited consent command are
read. Run `npm run evolution:report` (or pass `--report`) to print the current
baseline (success/merge rate, evidence completeness, median duration) and the
manifest's own train/holdout split before any candidate is generated. The split
is repository-disjoint first: the newest time tail only chooses which
repositories are held out, a repository spanning the cut moves all of its rows,
and `trainRows`/`holdoutRows` record the effective sizes. An export whose
holdout would take every row is refused with `NO_TRAINING_ROWS`, and
`--verify` recomputes a written dataset's hash and id.

## Security and privacy

Expand Down
5 changes: 5 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,11 @@ Upstream `api_key:` hashes from log messages. Request bodies are never logged.
The development mail outbox is written with mode 0600 and contains approval links;
treat it as a secret.

The evolution export uses a read-only database handle and passes every exported
string through the same `redact()` before serialisation. It reads only
repositories an admin opted in through the audited consent command, so a
repository that never consented is absent from the dataset and its holdout.

### Headers

- `Content-Security-Policy` for HTML pages: self only, no inline scripts, images
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@
"db:restore-drill": "node scripts/restore-drill.mjs",
"role:set": "node scripts/set-role.mjs",
"role:set:dev": "tsx scripts/set-role.ts",
"evolution:export": "node scripts/evolution-shadow.mjs",
"evolution:report": "node scripts/evolution-shadow.mjs --report",
"evolution:export": "tsx scripts/evolution-shadow.ts",
"evolution:report": "tsx scripts/evolution-shadow.ts --report",
"db:restore-drill:ci": "node scripts/restore-drill-ci.mjs",
"artifacts:prune": "node scripts/artifact-retention.mjs",
"secret:scan": "node scripts/secret-scan.mjs",
"slo:report": "node scripts/slo-report.mjs",
"trial:sweep": "tsx scripts/trial-sweep.ts",
"visual:matrix": "tsx scripts/visual-matrix.ts",
"evolution:consent": "tsx scripts/evolution-consent.ts",
"lint:update": "node scripts/lint-warnings.mjs --update"
},
"dependencies": {
Expand Down
45 changes: 45 additions & 0 deletions scripts/evolution-consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env node
// #425: the consent switch. A repository enters an evolution dataset only when an
// admin or superadmin turns it on here; the change is audited and the exporter
// reads nothing else. `#235` owns the settings-card control, so this slice ships
// the operator path only.
//
// Run with tsx: `npm run evolution:consent -- --repo owner/name --on --actor <login>`
import { loadConfig } from '../src/config.js'
import { openStore } from '../src/db.js'

function arg(name: string, fallback = ''): string {
const index = process.argv.indexOf(name)
return index === -1 ? fallback : (process.argv[index + 1] ?? fallback)
}

const repoName = arg('--repo', '').trim()
const on = process.argv.includes('--on')
const off = process.argv.includes('--off')
const actorLogin = arg('--actor', process.env.EVOLUTION_ACTOR ?? '').trim()

if (repoName === '' || on === off || actorLogin === '') {
console.error('usage: npm run evolution:consent -- --repo <owner/name> <--on|--off> --actor <github-login>')
process.exit(1)
}

const config = loadConfig()
const store = openStore(config.dbPath)
try {
const actor = store.userByLogin(actorLogin)
if (actor === undefined) throw new Error(`actor not found: ${actorLogin}`)
// The operator path is audited; the role check is what keeps a non-admin from
// widening the dataset, exactly as the console would.
if (actor.role !== 'admin' && actor.role !== 'superadmin') {
throw new Error(`refused: ${actorLogin} is ${actor.role}, and consent is admin-only`)
}
const target = store.listEvolutionConsent().find((row) => row.repo.toLowerCase() === repoName.toLowerCase())
if (target === undefined) throw new Error(`repository not found: ${repoName}`)
store.setRepoEvolutionConsent(target.repoId, on, actor.id)
console.log(`consent ${on ? 'on' : 'off'}: ${target.repo}`)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
} finally {
store.close()
}
183 changes: 0 additions & 183 deletions scripts/evolution-shadow.mjs

This file was deleted.

Loading
Loading