[feat] pluggable risk taxonomies and behavior sets - #27
Merged
Merged
Conversation
The taxonomy and the M1-M7 behaviors were hardcoded: risks.json, motivations.json and mechanisms.ts were statically imported at module scope and permanently memoized, with no flag, env var or API to point anywhere else. Evaluating against a different taxonomy meant editing the package. The real taxonomies and scenario sets are moving to infra, where some will be private, so the library needs a seam that accepts them as data. Adds a pack model (RiskTaxonomy, BehaviorSet) as plain serializable data, so a pack can round-trip through a database column. Two entry points: Packs.configure() for a single-tenant process (the CLI resolves --taxonomy / --behaviors once per invocation), and Packs.run() scoped by AsyncLocalStorage for kora-infra, which serves several runs concurrently from one Cloudflare isolate and cannot use a mutable global. RiskCategory, Mechanism and Motivation keep their exact public API and become facades over the active pack, so none of the ~37 call sites across this repo and kora-infra move. The load-bearing change is deferring schema construction. mechanismAssessment.ts built its schema at module scope, and that schema is pulled transitively into judgeAssessment.ts, testResult.ts and kora.ts, so merely importing the barrel froze the behavior set at import time. cli.ts statically imports both the barrel and every command, so no reordering fixes it. These are now getters backed by a WeakMap keyed on the active BehaviorSet; every v.parse(kora.testResultType, ...) call site is untouched and now resolves against the active pack. Deliberately not v.lazy: MechanismAssessment.io is handed straight to outputType and converted by @valibot/to-json-schema, which renders lazy as a root-level $ref that structured-output modes reject -- verified empirically, and it would fail at judge time, after the target conversation is already paid for. packages/cli/src/__tests__/jsonSchema.test.ts is the tripwire, and packs/__tests__/moduleInit.test.ts is the regression guard for the freeze. Scenario conformance is now checked up front. run, expand-scenarios, reassess and continue validate their whole input, and --risk-ids, before constructing any model, and fail with the offending line numbers instead of skipping records mid-run (kora-infra's testRunner has been warn-and-skipping these, which silently produces a short run). Adds `kora validate` for the same check on its own. Results, per-test results and generated seeds carry a pack stamp. The behavior fingerprint is folded into run temp-file names only when a custom pack is active, so a --behaviors change misses the graceful-restart cache instead of failing to parse a strict object with different keys, and default runs keep byte-identical names. data/mechanisms.ts becomes data/behaviors.json. A hand-written TS module is a second, privileged code path no infra-fetched pack can take, so the bundled default would be validated differently from every real pack. Preconditions are now stored as bare condition text and the surrounding notTriggered instruction is generated, replacing two hardcoded "M3/M5/M6/M7" prose sites -- including the one in the judge's JSON schema, which would otherwise describe mechanisms a custom pack does not have. mechanism.excelId becomes behavior.code (not persisted anywhere). Naming: new pack code says "behavior", while the existing Mechanism types and the persisted mechanismAssessment / sums.mechanisms fields keep "mechanism". The full rename is deferred on purpose -- behaviorAssessment was a v1 field name with an incompatible meaning (three an/eh/hr behaviors), and kora-infra's compatibility readers still tell v1 from v2 by which field name is present. Validated end to end on a new risk that exists in no bundled category: 10 seeds, 10 scenarios and a 10-test run against gemini-2.5-flash judged by claude-haiku-4.5. The judge's reasoning quotes criteria that appear only in the custom risk description, so the pack drives the evaluation rather than merely loading. The same scenarios are accepted by the custom pack and rejected by the bundled one. kora-infra is unchanged and still builds against this. Per-run packs additionally need the two module-scope schema reads there (app-website testResultCompat.ts, worker-engine regradeWorkflow.ts) deferred, plus taxonomy storage and run->pack threading; those follow separately.
Thibaut-Fatus
added a commit
that referenced
this pull request
Aug 31, 2026
…hooks (#28) packs.ts created its AsyncLocalStorage from a static `node:async_hooks` import. The browser reaches that module — Mechanism.listAll() and RiskCategory.listAll() both read Packs.current() — so every client bundle importing the barrel pulled a node builtin in. Bundlers externalize the builtin to a stub and then fail on the named import; kora-infra's website build has been broken since #27: "AsyncLocalStorage" is not exported by "__vite-browser-external", imported by ".../packs/packs.js" The scope now comes in through the `#packScope` subpath import, resolved by condition in package.json: - node/workers (default): packScope.node.ts, the real AsyncLocalStorage. A server runs several runs concurrently in one isolate, so the active pack still has to follow each one across its awaits. - browser: packScope.browser.ts, a save/restore stack with no node import. A page renders against one pack, and `Packs.run()` is only ever called with a synchronous callback, so the stack is accurate for that shape. Both conditions point at build output, so the specifier resolves for consumers but not for vitest running from source; the vitest config aliases it back to the node implementation, which also keeps an unbuilt checkout testable. Tests cover both implementations against the same behavior — no store outside run(), the store visible inside, and the enclosing store restored on return and on throw — plus a guard that packs.ts imports no node builtin, since nothing else would catch the regression before a downstream client build. tsbuild, 237 tests and prettier pass; lint unchanged (1 pre-existing warning).
Thibaut-Fatus
added a commit
that referenced
this pull request
Aug 31, 2026
#28 resolved the pack scope by condition, and a Cloudflare build asks for `browser` as well as `workerd`. With `browser` the only condition listed ahead of `default`, the worker bundle got the stack scope: verified in kora-infra, where app-website's dist/server/assets/worker-entry-*.js shipped StackScope. That is the runtime the async scope exists for. A worker serves several runs concurrently in one isolate, and the stack scope drops the active pack at the first await — silently, with the right types and green tests, exactly the mis-binding #27 set out to prevent. `workerd` now precedes `browser` and answers with the node implementation; workerd exposes AsyncLocalStorage under nodejs_compat, which all three kora-infra workers set. The order is the whole fix and nothing else would catch it losing, so the guard asserts it directly against the manifest — it fails if the two conditions are swapped. tsbuild, 239 tests and prettier pass; lint unchanged (1 pre-existing warning).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The taxonomy and the M1–M7 behaviors are hardcoded.
risks.json,motivations.jsonandmechanisms.tsare statically imported at module scope and permanently memoized, with no flag, env var or API to point anywhere else — evaluating against a different taxonomy means editing the package.The real taxonomies and scenario sets are moving to infra, where some will be private, so the library needs a seam that accepts them as data, not as a file path:
worker-enginehas no filesystem, and one Cloudflare isolate serves several runs concurrently.What
Packs (
packages/benchmark/src/packs/) —RiskTaxonomyandBehaviorSetas plain serializable data, so a pack can round-trip through ajsonbcolumn. Two entry points:Packs.configure()— process-wide, one-shot. The CLI resolves--taxonomy/--behaviorsonce per invocation.Packs.run()—AsyncLocalStorage-scoped, for infra's concurrent isolate.RiskCategory,MechanismandMotivationkeep their exact public API and become façades over the active pack, so none of the ~37 call sites across this repo andkora-inframove.The load-bearing change is deferring schema construction.
mechanismAssessment.tsbuilt its schema at module scope, and that schema is pulled transitively intojudgeAssessment.ts→testResult.ts→kora.ts— so merely importing the barrel froze the behavior set at import time, andcli.tsstatically imports both the barrel and every command, so no reordering fixes it. These are now getters backed by aWeakMapkeyed on the activeBehaviorSet. Everyv.parse(kora.testResultType, …)call site is untouched.Conformance is checked up front.
run,expand-scenarios,reassessandcontinuevalidate their whole input — and--risk-ids— before constructing any model, failing with the offending line numbers instead of skipping records mid-run. Plus a newkora validate.Provenance. Results, per-test results and generated seeds carry a pack stamp. The behavior fingerprint is folded into run temp-file names only when a custom pack is active, so a
--behaviorschange misses the graceful-restart cache instead of failing to parse; default runs keep byte-identical names.data/mechanisms.ts→data/behaviors.json. A hand-written TS module is a second, privileged code path no infra-fetched pack can take — the bundled default would end up validated differently from every real pack. Preconditions are now bare condition text with thenotTriggeredinstruction generated, replacing two hardcodedM3/M5/M6/M7prose sites — including the one that goes into the judge's JSON schema, which would otherwise describe mechanisms a custom pack does not have.Two guards worth keeping
packs/__tests__/moduleInit.test.ts— imports the barrel first, then configures a pack. Catches any reintroduced module-scope read of a pack-dependent schema, which no type error would.cli/src/__tests__/jsonSchema.test.ts— asserts the judge schema converts to a plain root object.v.lazyrenders as a root-level$refthat structured-output modes reject (verified empirically), and it would fail at judge time, after the target conversation is already paid for.Validation
Ran the full pipeline on a risk that exists in no bundled category — 10 seeds → 10 scenarios → 10-test run against
gemini-2.5-flash, judged byclaude-haiku-4.5:taxonomyId: "kora-packtest"; every per-test result andresults.jsoncarry the pack stamp.yarn tsbuildclean, 230 tests pass, prettier clean, lint at the pre-existing baseline.On naming
New pack code says behavior; the existing
Mechanismtypes and the persistedmechanismAssessment/sums.mechanismsfields keep mechanism. The full rename is deferred deliberately:behaviorAssessmentwas a v1 field name with an incompatible meaning (threean/eh/hrbehaviors), andkora-infra's compatibility readers still tell v1 from v2 by which field name is present. Renaming back would give that field two meanings and silently break the discriminator.Follow-up (kora-infra)
Unchanged and still builds against this branch. Per-run packs additionally need:
app-website/src/utils/testResultCompat.ts:22andworker-engine/src/regradeWorkflow.ts:68. Both currently bind permanently to the bundled behavior set.run→ pack threading, replacingtestRunner.ts:84's warn-and-skip with the up-front check.app-website/app-adminresolving a stored run's pack instead of callingRiskCategory.listAll()bare — two packs may reuse a risk id, so results must never be aggregated across packs.🤖 Generated with Claude Code
https://claude.ai/code/session_01GMgz7NFRGqcbfuyVjwUJdw