Skip to content

Spec 009: plan, research, contracts and tasks — the Spec Kit stages that had never run - #228

Merged
adamjohnwright merged 3 commits into
mainfrom
spec/009-plan-and-tasks
Sep 17, 2026
Merged

adamjohnwright merged 3 commits into
mainfrom
spec/009-plan-and-tasks

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Closing the process gap before building anything. Every spec in this repo had a spec.md and nothing else, so /speckit-plan, /speckit-tasks and /speckit-analyze had never run — and analyze cannot run at all without plan.md and tasks.md. Spec 008 shipped with no cross-artifact check.

009 now has the full set: plan.md, research.md, data-model.md, contracts/intent_classifier.md, quickstart.md, and 27 tasks.

What doing it properly turned up

A design constraint, found by reading the code rather than assuming. The RAG chain is built once at profile construction and closes over a single HybridRetriever, so a per-question collection selection cannot be a constructor argument. Rebuilding per question would re-tokenise 17,004 documents for reactions alone — that's startup work, not per-message work.

It travels through RunnableConfig["configurable"] instead. Not invented for this: base.py already reads config["configurable"].get("enable_postprocess"), so the mechanism is established here, and its failure mode is a missing key → "all collections" → today's behaviour.

An inconsistency the analysis caught. D1 and D2 were still phrased as "Recommendation", while plan.md and data-model.md had already built on them as decided. They're recorded as decisions now — with a note saying that's what happened, rather than the spec being quietly rewritten to match its own downstream documents.

The gate that is not met, written down

Four of the five collections have no sweep question that fails if routing stops searching them. That's Phase 2, and it blocks the feature: until it closes, the acceptance criterion cannot detect the failure this feature can cause.

Phase 2 is worth landing on its own regardless — it closes a hole that exists in the gate today, whether or not routing is ever built. If routing later proves to cost more recall than it saves, that PR still stands.

Constitution gates, evaluated rather than asserted

Principle How the plan satisfies it
I. Verify the user's path The served path is aretrieve_documents; acceptance runs answer-sweep through the whole graph, and T017 asserts both paths filter alike
II. Measure retrieval changes Baseline captured before any code change (T002), diff reported not gated
III. Characterization tests T009 pins "all collections searched" before changing it
IV. Fail loudly An unknown collection name logs WARNING and falls back to all — never a silently narrower search
V. Source of truth Selectable collections derived from list_chroma_subdirectories() on the live bundle, not a literal

No code changes. Docs only.

🤖 Generated with Claude Code

adamjohnwright and others added 3 commits September 17, 2026 13:41
HybridRetriever implements retrieval twice -- retrieve_documents and
aretrieve_documents, the second gathering coroutines so collections are queried
concurrently. Nothing exercised the async one, and that is the one the
application serves through. bin/retrieval_baseline, the tool the constitution
names for measuring retrieval changes, drives the sync one. If they drift,
every measurement is of a path no user takes.

They do not drift today: same documents, same order, checked across three query
shapes.

Two things this test had to be argued out of claiming. It first reported the
paths returning different document sets -- that was FakeEmbeddings returning a
fresh random vector per call, so the same query embedded differently on the
second run. DeterministicFakeEmbedding fixes it, and the near-miss is recorded
in the docstring because the false result is more instructive than the true one.

And it does not cover the per-collection cap. Deterministic fake vectors carry
no relation to the text, so every query retrieves much the same set and fusion
lands on exactly the cap however many queries are given -- changing the cap on
one path alone stays invisible. Tried at 12 and 60 documents per collection with
one, three and seven queries; ten per collection every time. That limit is
written into the docstring rather than left as apparent coverage, and covering
it needs real embeddings, which is retrieval_baseline's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new equivalence test failed on GitHub and passed here. BM25 tokenises with
word_tokenize(..., language="english"), which needs punkt_tab; the Dockerfile
installs it at build time and the CI test job never did. So the suite passed on
any machine where a developer had downloaded it once and failed on a clean one
-- the classic shape, and it was my test that exposed it rather than caused it.
Any future test that drives retrieval would have hit the same wall.

CI now fetches the same resource the Dockerfile does, so the async path is
genuinely covered there rather than skipped.

The test also skips with a message naming the download command when the
resource is absent, so a developer without it gets that instead of a LookupError
raised from inside nltk. Checked by patching nltk.data.find to raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every spec here had a spec.md and nothing else, so /speckit-plan, /speckit-tasks
and /speckit-analyze had never run -- and /speckit-analyze cannot run at all
without plan.md and tasks.md. 008 shipped with no cross-artifact check.

009 now has the full set: plan.md with the constitution gates written out,
research.md, data-model.md, contracts/, quickstart.md and 27 tasks.

Two things came out of doing it properly rather than by hand.

The first is a design constraint found by reading the code instead of assuming:
the RAG chain is built once at profile construction and closes over a single
HybridRetriever, so a per-question selection cannot be a constructor argument.
Rebuilding per question would re-tokenise 17,004 documents for `reactions`
alone. It travels through RunnableConfig["configurable"] instead -- which
base.py already uses for enable_postprocess, so the mechanism is established
here rather than invented.

The second is what the analysis caught: D1 and D2 were still phrased as
"Recommendation" while plan.md and data-model.md had already built on them as
decided. They are recorded as decisions now, with a note saying that is what
happened, rather than the spec being quietly rewritten to match its own
downstream documents.

The plan also writes down the gate that is not met: four of five collections
have no sweep question that fails if routing stops searching them. That is
Phase 2 and it blocks the feature, because until it closes the acceptance
criterion cannot detect the failure this feature can cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 9912a32 into main Sep 17, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the spec/009-plan-and-tasks branch September 17, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant