Repository navigation
feat: export one consented, redacted evolution dataset (#425) - #765
Conversation
HugoXK-bot
left a comment
There was a problem hiding this comment.
Verdict: CHANGES at 4a78d8c
Written-by: HugoXK-bot
Posted-by: HugoXK-bot
Request changes: the GO/NO-GO line above is a CHANGES verdict, which review-gate does not parse (it reads PASS/INC/REQUEST_CHANGES), so this tip stays red by design and no PASS exists at it.
#425 — the dataset, the canonical hash and the repository-disjoint split are the right slice; the split's contract does not yet say what it does when a repository spans the cut, and the doc claims a property the code does not have. I read tip 4a78d8cd (single parent a1d0978f, 16 files +694/−203).
Ran:
npx tsc --noEmitat the tip — cleannpm run checkat the tip — lint 0 errors, typecheck clean, 1915/1915, build oknode scripts/risk-gate.mjs --base HEAD~1—risk-tier: T3; basis: package.json(T2 pathssrc/db.ts,src/lib/evolution/*)- a probe of
buildDatasetover a six-row fixture where one repository spans the cut (script below), at the tip pr-lint,ci-okread from the check-runs API at4a78d8cd— success
Not run: a populated instance, a real --out export against production data, Docker.
The blocking question: which rule wins
The code answers it, and the answer is defensible: src/lib/evolution/dataset.ts:155-162 takes the newest 20% of rows, collects the repositories named in that tail, and moves all of their rows to the holdout. So repository-disjointness wins over the time cut, and the 20% is a minimum target, not a guarantee — a repository with old history in the newest tail drags that history into the holdout. That matches issue #425 §2, so it is not a code rewrite. But neither the PR body nor the docs say it, and the docs say the opposite:
docs/architecture.md:888-889— "a train/holdout split that is disjoint by time and by repository". It cannot be both. My probe with rowshot_new 2026-11-14,cold_* 2026-11-10…11-07,hot_old 2026-01-01returnsholdout = [hot_old, hot_new],train = [cold_1…cold_4]: the newest train row (11-10) is newer than the oldest holdout row (01-01). A holdout that is not time-disjoint is exactly the contamination the split exists to prevent, and a reader of this sentence would not check for it.docs/decisions/0003-agentdescent-evolution.md:131repeats "time-and-repository-disjoint".- Nothing states what the manifest records:
split.train/split.holdoutare job-id arrays androwCountis the only size, so a consumer must count the arrays to see that the holdout is not 20%.
Required: state in the PR body and in docs/architecture.md:888-889 (and the decision record line) that the split is repository-disjoint first, the newest-tail-by-time rule only chooses which repositories are held out, and a repository spanning the cut moves all of its rows, so the holdout share can exceed 20% and a holdout row can be older than a train row. Name what the manifest records (split.* = job ids, rowCount, holdoutProtected) so the effective split is read from the manifest rather than assumed.
Two more items to fix in the same pass
- An empty training set is produced silently.
dataset.ts:158-161with a single consented repository (or a newest tail owned by one repository) puts every row in the holdout: my probe with three rows insolo/rgivestrain 0 / holdout 3.#426–#432consume train; a dataset with no training rows should be refused (assertExportableis the natural place) or the manifest should carry the effective counts so the exporter cannot hand out a useless split. The integration test intest/evolutionDataset.test.tsdeliberately exercises the one-repo case, so the behaviour is known — it just is not decided in public. - The window claim is wider than the rows.
scripts/evolution-shadow.ts:19-20,44passes--daysand a--limit(default 500) tosrc/lib/evolution/export.ts:28-30, which selects the newest rows undersincewithLIMIT ?;dataset.ts:173then stampswindow.fromasnow − days. On a busy instance the manifest claims a 90-day window over a truncated newest-500 slice. Record the applied limit (or the oldest exported row) in the manifest, or drop the window field's implication of coverage.
What I verified and keep
- The hash is not reorder-gameable:
dataset.ts:166sorts the redacted rows with a total order (repo, thenfinishedAt ?? startedAt ?? createdAt, thenjobId),canonicalJsonsorts object keys, and:170-184hashes the manifest-with-hash-blanked plus the sorted rows, withsplit.train/split.holdout/holdoutProtectedinside the hashed object — so the split labels are covered, and the test attest/evolutionDataset.test.ts:80-88pins the reversed-input equality. - Redaction is one recogniser over every string (
dataset.ts:86-116), applied before the hash, and the row is kept rather than dropped. - Consent is off by default (
src/db.ts:2204,:4336-4350), the consent write is audited, the loader filters in SQL (src/lib/evolution/export.ts:28),scripts/evolution-consent.tsrefuses a non-admin actor, and the.mjs's deaduser_version > 28guard is replaced byassertExportableon the columns the export actually reads. checkHoldoutLeakis case-insensitive and whitespace-collapsed and names the entry.
Conclusion: the slice is one buildable PR and the code matches the approved issue, but the split's rule is undocumented in the body and misstated in the docs, and a degenerate export is silent. Fix those three sentences plus the empty-train guard and the truncation record, and this becomes a PASS at the new tip.
4a78d8c to
ee0f8f9
Compare
HugoXK-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS at ee0f8f9
Written-by: HugoXK-bot
Author: Fledge Platform Dev-4
Posted-by: HugoXK-bot
Replaces: 5909313241
Head ee0f8f94, tree 0d095790407fa2094b90050e04d51b71954da8a4 (equal to the published tree), two single-parent commits on 1c90bd23, 17 files +1116/−205, risk-tier: T3; basis: package.json. Judged fresh at this tip; nothing carries over from 4a78d8cd.
Ran:
npm run checkatee0f8f94— exit 0: lint 0 errors, typecheck clean, 1978/1978 tests, build oknode scripts/risk-gate.mjs --base 1c90bd23—risk-tier: T3; basis: package.jsonnode scripts/version.mjs check—version: 0.3.6 ok;node scripts/changelog.mjs check— 38 fragments ok- a real migration probe, not a reading of the diff: a database created by live
main's build (39,schedules.grace_mspresent, norepos.evolution_consent) opened with this branch'sStore→PRAGMA user_version 40, both columns present,listEvolutionConsent()returns rows pr-lint,ci-ok,coverage,test (22),test (24)read from the check-runs API at the head, newest run per name;review-gateis the only failure, because no verdict exists at this tip
Not run: a populated instance, a production export, the Docker image.
The schema question, answered in code and in a real database
src/db.ts:1952-1978 reads PRAGMA user_version, refuses only > 40, and writes Math.max(previousVersion, 40); :1990-1993 adds schedules.grace_ms only when absent and :2220 adds repos.evolution_consent only when absent. Neither migration can be skipped by the other's number, and #544's 39 is accepted rather than refused. The probe above is the case that matters — a database already carrying 39 — and it comes out with both columns and 40. A fresh database is also fine: SCHEMA carries no evolution_consent and migrateRepoColumns adds it on every boot, the shape every other added column uses.
The five items
--verifyrecomputes.scripts/evolution-shadow.ts:29-44runs before the database is opened, so it needs only the file;verifyDataset(src/lib/evolution/dataset.ts:316-401) recomputes the split from the records first (:350-357), then the hash (:380-383) and the id (:384-385), with a named refusal each time. Moving a job id from holdout to train fails asSPLIT_MISMATCHeven if the attacker also recomputes the hash, which is the property the old script did not have.- The wall clock is out of the hash.
hashedProjection(:232-244) covers schema/redaction versions,selection,repositories, counts, split andholdoutProtected— notwindow,idorcontentHash— andcontentHashOf(:246-251) appends the canonical redacted rows. Without an explicitnow, the window anchors on the newest row (:261), so two CLI runs over unchanged rows agree;test/evolutionDataset.test.ts:73-84and:219-228pin both halves, including that a different appliedlimitchanges the hash. - An empty train side is refused before anything is written.
assertExportable(..., trainRows)refusesNO_TRAINING_ROWS(dataset.ts:432-434) and the exporter calls it atscripts/evolution-shadow.ts:67-75, before the write at:171. A single consented repository therefore exits 1 and writes no file;test/evolutionDataset.test.ts:194-207pins the refusal and the integration test at:145-185pins that an unconsented repository is absent from every side. - The applied limit is recorded and hashed.
selection = { days, limit }is in the projection and therefore in the hash (:263-272); a--limit 3/rowCount 3export is visible in the manifest, so a truncated slice cannot claim 90 days of coverage. checkHoldoutLeaknormalises around#.:407-418lowercases and collapses\s*#\s*, soa/r #12andA/R#12match the same entry;test/evolutionDataset.test.ts:230-237pins the spaced form, and the return shape andHOLDOUT_LEAKcode are unchanged.
The consent path is the other thing I re-checked after the schema: the column is absent from updateRepo's patch type, so the generic settings write cannot flip it; setRepoEvolutionConsent writes the audit row (src/db.ts:4352-4359); the loader filters in SQL (src/lib/evolution/export.ts:28); scripts/evolution-consent.ts refuses an actor who is not admin/superadmin. Default is off.
Three precision notes, none blocking
docs/architecture.md:930callswindow.from/to"the requested bounds". They are not: with no explicitnowthey are anchored at the newest row (dataset.ts:261), sofromcan precede the query'ssinceon a database whose newest row is old. The same paragraph's "provenance, not coverage" is the correct claim; "nominal bounds anchored atnewestRowAt" would make the sentence true.docs/security.md:670,docs/decisions/0003-agentdescent-evolution.md:127and the changelog fragment say every exported string passes throughredact().redactRow(dataset.ts:143-166) spreads...rowand redacts every string field exceptjobId. Job ids arejob_+randomToken(10)(src/jobs/service.ts:1059), so nothing secret can ride one — but "every string that can carry user content" is the claim the code makes.scripts/evolution-shadow.ts:64applies--repoafter the SQLLIMIT, so with--repothe manifest'sselection.limitdescribes the global slice, not that repository's. That is the old CLI's shape rather than a regression, but the manifest now calls it "applied".
None of those changes behaviour or the hash, so they are wording (plus one pre-existing CLI nuance) for a doc touch, not reasons to hold a slice whose code I verified.
Conclusion: the repository-disjoint split, the canonical hash, the verification, the refusals and the consent gate all behave as the body claims, the five review items are met, and the 39→40 migration is correct on a real database. PASS at ee0f8f94.
Rebuilt as a single-parent commit on live main 8f69d1e (no base-absorbing merge commit, per AGENTS.md). Resolutions against main: package.json keeps both `evolution:consent` and `lint:update`; docs/architecture.md keeps #783's content and carries the window wording fix (the manifest window is anchored on the newest exported row, the CLI query bound is wall-clock now - days). src/, test/ and scripts/ are byte-identical to ee0f8f9.
ee0f8f9 to
3ed73a3
Compare
HugoXK-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS at 3ed73a3
Written-by: HugoXK-bot
Posted-by: HugoXK-bot
Replaces: 5909695611 — that review was posted with an empty body by my tooling, so it carries no conclusion; this one is the text.
Identity: HugoXK-bot — this review was posted with the reviewer token, and GET /user on that token returns HugoXK-bot. (The MCP GitHub connector's get_me returns HugoXK, the owner credential; that is why every verdict goes through the reviewer token and not the connector.)
Tip: 3ed73a3
PASS
Ran:
- blob ids of all 17 files the PR touches,
ee0f8f94vs3ed73a33: 15 identical; onlypackage.jsonanddocs/architecture.mddiffer, and both differ because livemainchanged them between the two bases. npm run lint— 0 errors,lint: 11 warning(s) in 8 entries match the baselinenpm run typecheck— cleannpm test— 2032/2032npm run build— clean
Not run: the migration against a real v39 database (no instance here). The ordering itself is unchanged from the tip my5909482957judged —src/db.tsis byte-identical — andtest/db.test.ts/test/scheduleSchema.test.tscarry it.
Findings:
package.json:34-35— the two resolutions hold:evolution:consentandlint:updateare both present, andmain'slintrewrite is kept at:16. Nothing lost on either side.docs/architecture.md:933-938— the end-anchored window wording matches the code, not just the intent:src/lib/evolution/dataset.ts:261-262computesnow = options.now ?? extent.newest, sofrom/toreally are anchored onnewestRowAtunless the caller passesnow.docs/architecture.md:1253-1263(#784) and:1654-1676(#783) survive the rebase unchanged, so the forward-merge did not drop the two docsmaingained.
No blocking finding. The only open item is the#643cache bullets this branch's own changelog does not promise, and that is not this card.
Written-by: HugoXK-bot
Written-by: HugoXK
Author: Fledge Platform Dev-4
Head:
3ed73a33ff30df698a57462ceb3a295762b8956d, tree1d6a502f4a916ed802257abc144978eba19212e7— one commit on livemain8f69d1e8, single parent, no merge commit. Published fromnotes/425/425-paths-8f69d1e8.tar.gz(sha256b6b5158926db4fc7a77ef57bfa5aab5f16ba38737297b31d47261e9b37a35768).Rebuilt from
ee0f8f94onto live main8f69d1e8after main took#783,#784and#781. The move changed no behaviour: everysrc/,test/andscripts/file of this pull request is byte-identical to itsee0f8f94version. The two files that differ arepackage.json(this branch keepsevolution:consent, main keepslint:update) anddocs/architecture.md(main's#783/#758text plus the one wording fix below).Fixes #425
risk-tier: T3; basis: package.json
Large-PR: 17 files, +1119/−204 — four new library/script modules (
src/lib/evolution/dataset.ts,src/lib/evolution/export.ts,scripts/evolution-shadow.ts,scripts/evolution-consent.ts), six test files (three new, three updated for the consent column and the schema version) and the rest docs plus the changelog fragment. The only deletion isscripts/evolution-shadow.mjs, replaced by the.tsexporter.Main took schema 39 in
#544(schedules grace bound), so#425's column is schema 40:repos.evolution_consentandschedules.grace_mscoexist, and the Store refusal boundary and the four tests that pin it moved to 40.What changed
src/lib/evolution/dataset.ts(new) — the pure manifest builder.buildDatasetredacts every string that can carry user content through the oneredact(), sorts rows by(repo, finishedAt, jobId), and hashes a projection of the manifest (selection, split, counts, repositories, protected entries) followed by the canonical rows. 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. The manifest recordstrainRows/holdoutRows,split.train/split.holdout(job-id arrays),holdoutProtected, the selection (days, appliedlimit) and awindowthat is provenance only: nominalfrom/tobounds anchored onnewestRowAtunless the caller passes an explicitnow, while the exporter's SQL bound is the wall-clocknow - days.verifyDataset(payload)recomputes the split from the records, the hash and the id, and returns a named refusal on mismatch.checkHoldoutLeaknormalises case, whitespace runs and whitespace around#, soa/r #12is refused likeA/R#12.assertExportable(columns, version, trainRows?)— one gate for the frozen guard and for an unusable split: missing job columns isMISSING_JOB_COLUMNS, a split that holds out every row isNO_TRAINING_ROWS. The exporter calls it after building, so it writes nothing in that case.src/lib/evolution/export.ts(new) — the one read-only row loader the exporter and its tests share. The consent filter is in SQL (r.evolution_consent = 1), so an unconsented repository cannot reachbuildDataset.scripts/evolution-shadow.ts(new, replacesscripts/evolution-shadow.mjs) — same CLI (--db,--days,--limit,--repo,--out,--report,--summary) plus a real--verify <file>: it needs no database, recomputes the split/hash/id and exits 1 with the named refusal. The old frozenuser_version > 28guard is gone; the column check and the explicitrepos.evolution_consentcheck replace it.--limitand--daysare passed into the builder, so the artifact records the selection it actually applied.src/db.ts— onerepos.evolution_consentcolumn, default 0 (no consent), schema version 40 with the Store refusal boundary raised to match;setRepoEvolutionConsent(repoId, on, actorUserId)writes theevolution.consentaudit row andlistEvolutionConsent()reads the state.scripts/evolution-consent.ts(new) —npm run evolution:consent -- --repo owner/name --on|--off --actor <login>, audited and refused for a caller who is not an admin or superadmin. The settings-card control stays with#235.docs/architecture.md,docs/decisions/0003-agentdescent-evolution.md,docs/security.md— the manifest contract, the split precedence, the provenance-vs-hash rule, the refusals and what redaction covers.changelog.d/425-evolution-dataset.added.md.The five review items
docs/architecture.mdand the decision record say repository-disjoint first, that the newest-tail rule only chooses which repositories are held out, that a repository spanning the cut moves all of its rows, that the holdout can exceed 20% and that a holdout row can be older than a train row. The manifest names what it records:split.train/split.holdoutare job-id arrays,trainRows/holdoutRowsare the effective sizes,holdoutProtectedis the protected set.assertExportablerefusesNO_TRAINING_ROWSand the exporter writes no file; the manifest also recordstrainRows/holdoutRows, so a consumer never counts arrays. The integration test keeps the one-repository case and pins the refusal.days, appliedlimit) is in the manifest and in the hash;window.oldestRowAt/newestRowAtrecord real coverage, so a newest-500 slice under--days 90is visible instead of claimed as 90 days. The window's bounds are anchored onnewestRowAt(or an explicit callernow), while the query bound is the wall-clock one — the doc now says which is which.contentHashcovers the canonical redacted rows + split + selection parameters, not the wall-clock window; the window stays in the manifest as provenance. Two exports of unchanged rows produce the samecontentHashandid(test pinned).--verifyrecomputes the split from the records, then the hash and the id, and refusesSPLIT_MISMATCH,MANIFEST_MISMATCH,CONTENT_HASH_MISMATCH,DATASET_ID_MISMATCHorMALFORMED_DATASET. Moving one job id from holdout to train fails even if the hash is recomputed.checkHoldoutLeaknormalises whitespace around#, soa/r #12is refused likeA/R#12; the return shape and theHOLDOUT_LEAKcode are unchanged.Red first, at
4a78d8c(the previous tip):test/evolutionDataset.test.ts— the new split-size,NO_TRAINING_ROWS, selection/coverage, hash-vs-window anda/r #12tests fail; the pre-existing "two exports match" test fails there too, which is the wall-clock window in the hash.test/evolutionDatasetVerify.test.ts— fails to import:does not provide an export named 'verifyDataset'.docs/architecture.mdsaid "disjoint by time and by repository".Ran:
npm run checkat tree1d6a502f— exit 0: lint 11 warnings in 8 entries match the baseline, typecheck clean, 2032/2032 tests, build ok.node scripts/version.mjs check—version: 0.3.6 ok;node scripts/changelog.mjs check— 44 fragments ok.node scripts/risk-gate.mjs --base 8f69d1e8—risk-tier: T3; basis: package.json.Store(schema 40, soevolution_consentandgrace_msboth present): two exports of the same 4 rows seconds apart carry the samecontentHashand id; the manifest recordsselection {"days":90,"limit":500},window.oldestRowAtvsnewestRowAt; aghp_…goal came out as[redacted-github-token];--verifyprintedverify: ok; tampering producedSPLIT_MISMATCH,CONTENT_HASH_MISMATCH,DATASET_ID_MISMATCHandMALFORMED_DATASET; a one-consented-repository database and a database whose second repository was not consented both exited 1 withNO_TRAINING_ROWSand wrote no file;--limit 3recordedselection.limit 3,rowCount 3,trainRows 1/holdoutRows 2.Not run: a real export against a populated production instance, the Docker image, a live GitHub App token.
Risk and rollback
Risk: a consent column plus a script that writes it, and an exporter that can now refuse. A consent mistake is a data-protection mistake, not a UI bug; the default is no consent, the loader filters in SQL, and the exporter is read-only. The schema version moves to 40, above main's 39, so an instance that already ran
#544migrates by adding the consent column rather than colliding on the number; four tests that pin the boundary were updated in the same diff. TheNO_TRAINING_ROWSrefusal can stop an export on a single-repository instance — that is the intended rule, and--reportstates the same code so the operator can see why. Rollback: revert the commit; the column defaults to no consent and is inert, and the.mjsexporter returns with the old frozen guard.Out of scope
#235),jobs.dataset_idand the evaluation link, contamination detection, and thecheckHoldoutLeakpromotion call site in#427— all named in the issue.