Current state at handoff
Prod: oddkit.klappy.dev serving 0.20.0. Three tools in the sweep:
| Tool | Version shipped | Envelope fields |
|---|---|---|
oddkit_encode | 0.18.0 | governance_source, governance_uri (singular — encode's docs sit under a single canonical umbrella) |
oddkit_challenge | 0.19.0 | governance_source, governance_uris (plural array of 4 — peer governance files, alphabetical by path-tail) |
oddkit_gate | 0.20.0 | governance_source, governance_uris (plural array of 2 — peer governance files: transitions + prerequisites) |
Code baseline: klappy/oddkit main at merge-commit 260492c (feat PR #118) + 1308245 (promotion PR #119). Before touching code, run git pull on main — this handoff was written immediately after promotion; the tip should be stable but verify.
Canon baseline: klappy/klappy.dev main at e8be010 (P1.3.2 closeout ledger + superseded handoffs, PR #123). No pending canon-first prerequisites for P1.3.3 — challenge's four governance files have been live since 0.19.0.
Recent carry-forward reading:
klappy://odd/ledger/2026-04-20-p1-3-2-gate-canary-landed— the P1.3.2 closeout; names the D5 split-by-fit decision, the D9 no-microsecond-caching decision, and the candidate canon principle status in detail.klappy://odd/ledger/2026-04-20-p1-3-1-challenge-canary-landed— the P1.3.1 closeout; names the 5-corroboration Sonnet 4.6 validator pattern and thecontract-governs-handoff-driftprinciple.
Scopeexactly three items
Item 1 — Migrate evaluatePrerequisiteCheck from regex to stemmed set intersection (D5)
Current state (L2294): function takes (input: string, check: string) where check is a comma-separated vocabulary from canon (per-type quality criteria or per-base-prereq check column). Current implementation compiles check into a regex and runs regex.test(input).
Target state: parse check at canon-fetch time into a stemmed Set<string> stored on the parent struct (ChallengeTypeDef for per-type, BasePrerequisite for universal), same shape as GatePrerequisite.stemmedTokens in gate. At runtime, tokenize input once into inputStems: Set<string>, then test each check via Array.from(stemmedTokens).some(s => inputStems.has(s)).
Why: same D5 rationale applied to challenge. Prereq evaluation is independent gap-or-not per prereq, not ranked. Regex-per-check misses stemmed variations (problems identified fails \b(problem|goal)\b); stemmed set intersection catches them. Porter-style stemmer already shipped in workers/src/bm25.ts.
Call site (L2129): const passed = evaluatePrerequisiteCheck(input, p.check); — this lives inside runChallengeAction. After refactor, the function signature becomes evaluatePrerequisiteCheck(inputStems: Set<string>, stemmedTokens: Set<string>): boolean, and inputStems is computed once per runChallengeAction call outside the per-prereq loop.
Stemmer gemination caveat (from P1.3.2): the Porter-style stemmer drops -ing/-ed but doesn't reverse consonant gemination. If challenge's check vocabularies contain geminating words (shipping, stepped, etc.), add their inflected forms directly to canon check columns — same pattern gate used for odd/gate/transitions.md in klappy/klappy.dev#122. Check for this before shipping; it's the same bug class and can be caught at preview smoke.
Files changed: workers/src/orchestrate.ts. No canon file changes required — the check vocabularies already live in odd/challenge/base-prerequisites.md and per-type challenge docs. Only the parser needs to compute stemmedTokens at parse time and the runtime needs to consume them.
Estimated delta: ~40 lines across the per-type parser, the base-prereq parser, evaluatePrerequisiteCheck itself, and the call site.
Item 2 — Remove cachedChallengeTypeIndex, rebuild inline per request (D9)
Current state: module-level cache at L163–164 (cachedChallengeTypeIndex, cachedChallengeTypeIndexKnowledgeBaseUrl). Built lazily by getOrBuildChallengeTypeIndex at L634–651 on first request, reused thereafter. Reset in cleanup_storage at L1518–1519. Consumed at runChallengeAction L2028: const typeIndex = getOrBuildChallengeTypeIndex(types, vocab, knowledgeBaseUrl);.
Target state: delete the cache fields. Delete getOrBuildChallengeTypeIndex. Rebuild inline at the call site, same pattern as gate's transition-index build:
const bm25Docs = types.map((t) => ({ id: t.slug, text: t.detectionText }));
const typeIndex = buildBM25Index(bm25Docs, vocab.stopWords);
Why: per D9. BM25 index construction over challenge's tiny per-type corpus (typically 6-9 types, each with a short detection text) is a microsecond derivation. Caching it adds plumbing (cache fields + invalidation on source/url change + cleanup_storage resets + drift risk when source data changes) without throughput benefit. The cached parse products (cachedChallengeTypes, cachedNormativeVocabulary, etc.) stay — those are actual parse work. The index is just a reshape of the parse product.
Files changed: workers/src/orchestrate.ts. Four locations: delete at L163–164, delete the function at L634–651, delete cleanup_storage resets at L1518–1519, inline the build at L2028.
Estimated delta: ~25 lines removed, ~3 lines added (the inline build).
Item 3 — Graduate cache-fetches-and-parses to canon
Current attestation points (four, across the sweep):
- Encoding types parse caching (0.18.0):
cachedEncodingTypes— parse product, kept. ✓ - Base-prerequisites parse caching (0.19.0):
cachedBasePrerequisites— parse product, kept. ✓ - Gate's
stemmedTokensprecompute (0.20.0): per-prereq Set stored insideGatePrerequisiteat parse time. Parse product, cached. ✓ - Gate's BM25 index NOT cached (0.20.0, D9): inline build per request. Microsecond derivation, not cached. ✗ (intentional non-caching)
The third recurrence needed to graduate a candidate canon principle is a deciding-argument recurrence — a decision where the principle is the load-bearing reason, not a summary of prior choices. P1.3.1's D2/D3 decisions implicitly applied the pattern. P1.3.2's D9 was the first time the principle was named explicitly and used as the deciding argument. P1.3.3's Item 2 (cachedChallengeTypeIndex removal) is the second explicit application of D9 across tools — the third recurrence that satisfies the graduation test.
Target canon doc: klappy://canon/principles/cache-fetches-and-parses.md. Tier 2. Structure mirrors existing principle docs (e.g., klappy://canon/principles/vodka-architecture):
- Frontmatter:
tier: 2,date: 2026-04-20,derives_frompointing at the two ledgers and the core-governance-baseline - Blockquote with compressed argument: "Cache the expensive work — fetching over the network, parsing markdown tables, building stemmed token sets. Don't cache microsecond derivations — rebuilding a BM25 index over a cached parse product is cheaper than the plumbing required to cache it safely."
- Summary — The Principle Has Two Halves section naming the distinction
- What counts as a parse product section with the four attestation points
- What counts as a microsecond derivation section (BM25 indexes over small parse products; regex compilation from literal string lists; etc.)
- Trade-off section noting the size-threshold nuance: as corpus sizes grow, the rebuild cost moves. The principle is a rule-of-thumb anchored to the codebase's current corpus sizes (6–9 types, 4 transitions, 8 prereqs); revisit if any tool grows its governance corpus substantially.
- Related principles:
vodka-architecture(governance over code),contract-governs-handoff-drift(source-of-truth hierarchy)
Canon-first sequencing: this canon doc ships BEFORE the code PR that removes cachedChallengeTypeIndex. Same pattern as P1.3.2's Phase 1 canon preceding Phase 2 code. One small canon PR on klappy/klappy.dev; wait for merge + retrievability via oddkit_get; then open the code PR on klappy/oddkit referencing the canon URI.
Estimated effort for canon doc: 15-20 min once the execution phase begins.
Out of scope for P1.3.3
Named here so the scope stays bounded. Each is a legitimate follow-up for a future session:
- encode's
triggerRegexparagraph classification (L1113, L1155). Different tool, different code path. encode's batch-mode paragraph classification compiles a per-type regex at parse time (EncodingTypeDef.triggerRegexat L473) and tests each paragraph against it. Migration to BM25 top-hit or stemmed set intersection is a natural next beat but belongs to a separate encode-scoped PR. Don't pull it into P1.3.3. - Global
cachedBM25Indexat L218. This is the canon-level index used by canon search, not a challenge-specific cache. Separate concern; may or may not qualify as a microsecond derivation depending on canon corpus size (524 docs at last search — meaningfully bigger than challenge's 6-9 types). Size-threshold judgment; defer. - Sonnet 4.6 validator dispatch vs smoke-heavy self-review. P1.3.1 used Sonnet 4.6 with 5 corroborations. P1.3.2 used smoke-heavy self-review (9 smoke runs + live self-call). The P1.3.2 ledger named the tradeoff honestly; neither is strictly superior. P1.3.3 can go either way — see "Attestation options" below.
- Remaining O-opens from prior ledgers: P2 (
getIndexstrict-mode), P3 (handoff text corrections), P4 (draftcontract-governs-handoff-driftprinciple), P5 (workers/baseline build pipeline), P6 (CHANGELOG render route). All remain open; not part of P1.3.3.
Key design decisions already settled
The next session does not need to re-derive these; they are inherited from P1.3.2's PRD v4 and the ledger's Patterns section:
- D5 Split by fit. BM25 for ranking problems (transition detection, challenge-type detection). Set intersection for independent gap-or-not problems (prereq evaluation). Challenge inherits the split: type detection keeps BM25 (ranking — pick the best matching type); prereq evaluation migrates to set intersection (independent gap-or-not per prereq).
- D9 Don't cache microsecond derivations. BM25 indexes over small parse products are rebuilt inline per request. Parse products (the stemmed token sets, the parsed governance structs) stay cached.
- Uniform algorithm across tiers. challenge's minimal-tier fallback already exists for types, base-prereqs, normative-vocab, and stakes. The refactor doesn't change which tier is active; it changes the algorithm used inside the tiers uniformly. Minimal-tier fallback still produces the same shape of
ChallengeTypeDef/BasePrerequisitewith the newstemmedTokensfield populated.
Code locationsexact line references
Baseline: klappy/oddkit main 1acc452 (feat), merged as 260492c (squash into main). Verify git pull on main before starting — line numbers assume this baseline.
Remove (D9 — cachedChallengeTypeIndex):
- L163–164: module-level cache declarations
- L634–651:
getOrBuildChallengeTypeIndexfunction definition - L1518–1519: cleanup_storage reset entries
- L2028: call site in
runChallengeAction— replace with inlinebuildBM25Indexcall
Refactor (D5 — evaluatePrerequisiteCheck):
- L2294: function definition — change to accept pre-stemmed inputs rather than compile regex
- L2129: call site — move
tokenize(input)up and out of the per-prereq loop, passinputStemsin - Per-type parser in
discoverChallengeTypes(around L413–435): addstemmedTokens: Set<string>toChallengeTypeDefper-quality-criterion, populated at parse time viatokenize(criterion.check) fetchBasePrerequisitesparser (around L709+): addstemmedTokens: Set<string>toBasePrerequisite, populated at parse time viatokenize(p.check)
Keep (parse product caches):
- L160–162:
cachedChallengeTypes(parse product) — keep - L165–167:
cachedBasePrerequisites(parse product) — keep - L168–170:
cachedNormativeVocabulary— keep - L171–173:
cachedStakesCalibration— keep
Smoke test expansion
Existing challenge smoke block in workers/test/canon-tool-envelope.smoke.mjs at roughly L220–322. Current coverage: envelope shape, governance_source enum, governance_uris length-4 array, knowledge_base_url override, 9-mode parse integrity.
New assertions to add (~10):
- Stemmed prereq match for a per-type challenge — e.g.,
"we've identified the problem"stemsidentified→identifandproblem→problem; canon vocabulary for a challenge-type prereq includesproblem→ match. - Stemmed match for a base prereq — same pattern against universal checks.
- Non-match case — input with no stemmed overlap against canon vocab → prereq surfaces in missing list.
- Gemination check — if any challenge check vocabulary contains
ship/step/similar, test inflected forms and expect them to work (after canon amendment if needed). - Index-rebuild-per-request check — call challenge twice with the same input, assert both produce identical results (proxy for: the rebuild produces stable output).
No envelope shape assertions need to change. governance_source and governance_uris contracts are unchanged.
CHANGELOG sketch for 0.21.0
## [0.21.0] - 2026-04-21 (or whenever it ships)
### Changed
- **`oddkit_challenge` prerequisite evaluation migrated from regex to stemmed set intersection.** Per PRD D5 from P1.3.2 (split-by-fit): prereq evaluation is independent gap-or-not per prereq, not ranked; set intersection over stemmed tokens is the fit-to-problem matcher and avoids BM25's IDF-negative pathology on small shared-vocabulary corpora. Strictly additive: every input that matched the prior regex still matches; stemmed variations now match too. [Mention specific affected patterns based on what the refactor surfaces.]
- **`oddkit_challenge` type index cache removed.** Per PRD D9 from P1.3.2 (don't cache microsecond derivations): the BM25 type index over challenge's 6-9-type corpus is rebuilt inline per request. Parse-product caches (cachedChallengeTypes, cachedBasePrerequisites, cachedNormativeVocabulary, cachedStakesCalibration) remain — those are actual parse work.
### Added
- **New canon principle:** `klappy://canon/principles/cache-fetches-and-parses`. Graduates the "cache fetches and parses, not microsecond derivations" pattern to canon after its third deciding-argument recurrence (0.18.0 encode parse caching, 0.20.0 gate D9, 0.21.0 challenge `cachedChallengeTypeIndex` removal).
### Known limitations
- Same as 0.20.0 (stemmer gemination gap, getIndex strict-mode).
Attestation optionsoperator's call
Per P1.3.2's ledger on the tradeoff:
Option A — Smoke-heavy self-review (P1.3.2 pattern): preview-smoke 3× clean, CI smoke on PR, main-preview smoke 3× post-merge, prod smoke 3× post-promotion + warmup, live self-call from session's MCP connection. Catches mechanical bugs. Misses PRD-vs-PR intent drift. Total: ~9-10 smoke runs + one live call.
Option B — Sonnet 4.6 validator dispatch (P1.3.1 pattern): 5-corroboration protocol adapted for this PR. Main-branch bytes grep + live preview curl of the key envelope shapes + canon citation + canon files retrievable + independent smoke runs. Catches intent drift. Costs more time and a separate agent session.
Recommendation given smaller scope: Option A is fine for P1.3.3. No envelope changes, no new governance files, no multi-phase sequencing. The one real risk is stemmer gemination surfacing again at preview-smoke (same as P1.3.2), which is exactly the class of bug Option A catches concretely. If any smoke failure surfaces something that requires judgment-call resolution (rather than obvious canon-vocab-or-code-fix), escalate to Option B.
Not recommended: skipping both. Both tools in prior sweep steps had attestation.
Path 3 workflow
- Canon PR first. Write
canon/principles/cache-fetches-and-parses.mdon klappy/klappy.dev. Small PR. Self-review squash-merge. Verify retrievable viaoddkit_get. - Code branch. Cut
challenge/d5-d9-parityoff klappy/oddkit main. - Apply code edits in the order above (Item 2 is simpler — remove cache first, verify tests still pass; then Item 1 — refactor
evaluatePrerequisiteCheck). - Typecheck + existing parser tests.
npm run typecheckinworkers/,node workers/test/governance-parser.test.mjs. - CHANGELOG + version bump to 0.21.0 (pre-commit hook enforces sync between root + workers package.json).
- Add smoke assertions.
- Commit + push. Wait ~25s for CF preview. Smoke preview 3× clean (watch for gemination surprises).
- If gemination surfaces: small canon PR to klappy/klappy.dev adding inflected forms to affected challenge check columns (same fix pattern as klappy/klappy.dev#122). Wait for merge. Amend commit + force-push. Re-smoke.
- Open feat PR via
urllib.request+json.dumps. Wait for CI (Test CF Preview typically takes 60-90s). - Self-review or dispatch validator per Option above.
- Squash-merge. Main-preview smoke 3× clean.
- Open main → prod promotion PR. Merge. Wait ~40s warmup. Prod smoke 3× clean.
- Write closeout ledger at
odd/ledger/2026-04-21-p1-3-3-challenge-canon-parity-landed.md(or date-appropriate). Flip this handoff tostatus: supersededwithsuperseded_bypointing at the new ledger. Document whether the third recurrence landed cleanly and whether the principle doc required any post-facto adjustment based on execution learnings.
Thin prompt for next session
Resume P1.3.3 per klappy://odd/handoffs/2026-04-20-p1-3-3-challenge-revisit.
Current state: oddkit 0.20.0 in prod. Three tools in the sweep carry
governance_source + governance_uris: encode (0.18.0 singular), challenge
(0.19.0 plural array of 4), gate (0.20.0 plural array of 2).
Phase 2 scope is bounded at three items: (1) migrate challenge's
evaluatePrerequisiteCheck from regex to stemmed set intersection (D5 —
same matcher gate shipped), (2) remove cachedChallengeTypeIndex and
rebuild inline per D9 (microsecond derivation, not worth caching), (3)
graduate 'cache fetches and parses, not microsecond derivations' to
canon at canon/principles/cache-fetches-and-parses.md (third deciding-
argument recurrence). No envelope changes. No new governance files. Ship
as 0.21.0.
Start by:
1. git pull on both klappy/oddkit main and klappy/klappy.dev main
2. Read this handoff end-to-end
3. Read klappy://odd/ledger/2026-04-20-p1-3-2-gate-canary-landed for the
D5 and D9 rationale in full
4. Read the existing canon principle doc at klappy://canon/principles/
vodka-architecture for the structure to mirror when writing
canon/principles/cache-fetches-and-parses.md
5. Write a short PRD as a working-directory file before touching code
(~100-150 lines; most design is reused from P1.3.2)
6. Canon PR first (canon-first absolute)
7. Code PR second
Credentials in project instructions. Orchestrator-applies for writes;
Sonnet 4.6 read-only for validation if Option B chosen.
Standing rules (carried forward, unchanged)
oddkit_timefirst every turn. Pass priorserver_timeasreference.- Declare epistemic mode out loud.
- Search canon before asking.
- Ground framing in code + canon, not the latest handoff (per
contract-governs-handoff-drift). - PRD before code.
- During execution, produce artifacts, not questions.
- Canon-first absolute.
- No force-push without
git fetchfirst. - GitHub API:
urllib.request+json.dumps, never bash heredoc. - No Opus 4.7 execution agents to klappy/oddkit. Sonnet 4.6 read-only for validation; orchestrator-applies for writes.
- No
wrangler deploymanually. - Branch preview slug rule: slashes AND dots → hyphens.
- Prod deploy warmup: accept 3 consecutive clean runs as the prod-ready gate after ~40s warmup wait.
- Same-session handoff anti-pattern: do not write a forward handoff for work that can be executed in the same session. This handoff is the legitimate cross-session case — the receiving session is a different agent in a different context window.
Carry-forward O-opens (unchanged from P1.3.2 ledger)
- P2
getIndexstrict-mode across encode/challenge/gate - P3 handoff text corrections (P1.2 "bundled baseline"→"minimal fallback"; P1.3
discover*→fetch*) - P4 draft
canon/principles/contract-governs-handoff-drift.md - P5
workers/baseline/build pipeline - P6 CHANGELOG render route on klappy.dev +
version_notes_urlon MCPinitialize - P8 (new) — encode's
triggerRegexparagraph classification migration (out of P1.3.3 scope per above) - P9 (new) — global
cachedBM25Indexreview (may have a size-threshold answer; out of P1.3.3 scope)
Provenance
- Prior ledger:
klappy://odd/ledger/2026-04-20-p1-3-2-gate-canary-landed - Recent PRs: klappy/oddkit#118 (feat
260492c), #119 (promotion1308245), klappy/klappy.dev#120 #121 #122 #123 - Session that wrote this handoff: 2026-04-20T01:21:45Z–2026-04-20T03:40Z (2h 18m — wrote this handoff at the end as session-close deliverable, after P1.3.2 shipped end-to-end). The handoff is genuinely forward-looking; the receiving session is a different conversation.