|
| 1 | +# Findings: can a skill-shipped gate replace the strata-qa Lambda? |
| 2 | + |
| 3 | +Date: 2026-07-28 |
| 4 | +Status: findings complete, decision open |
| 5 | +Issue: #42 |
| 6 | + |
| 7 | +## Question |
| 8 | + |
| 9 | +Can a Claude Tag skill that ships deterministic JavaScript gates preserve the grounding guarantee |
| 10 | +`strata-qa` enforces today, without deploying the Slack gateway #40 specifies? |
| 11 | + |
| 12 | +The guarantee at stake: `ground()` mechanically checks that every quote appears verbatim in the file |
| 13 | +it cites and demotes the model's own verdict when it does not, and `run.ts` calls it unconditionally. |
| 14 | +A model cannot claim `answered`; it can only propose it. A skill inverts that control, because the |
| 15 | +agent decides whether to invoke the gate. |
| 16 | + |
| 17 | +## Answer |
| 18 | + |
| 19 | +**The port is faithful. The invocation guarantee is not recoverable, only observable.** Everything |
| 20 | +that can be moved into code was moved into code and behaves identically to the original. What cannot |
| 21 | +be moved is the certainty that the code runs. |
| 22 | + |
| 23 | +## What was built |
| 24 | + |
| 25 | +Under `skills/answer-strata-question/`: |
| 26 | + |
| 27 | +| File | Lines | What it is | |
| 28 | +|---|---|---| |
| 29 | +| `SKILL.md` | 105 | The three-stage procedure | |
| 30 | +| `bin/verify-answer.mjs` | 322 | The gate: `ground()` plus the CLI the Lambda gets from `run.ts` and `cli.ts` | |
| 31 | +| `bin/verify-answer.test.mjs` | 404 | 20 cases ported from `grounding.test.ts`, 12 new for the CLI surface | |
| 32 | +| `bin/select-candidates.mjs` | 159 | Deterministic candidate selection over `docs/graph.json` | |
| 33 | +| `bin/differential.test.mjs` | 182 | Generated differential sweep against the original | |
| 34 | +| `bin/reference-bridge.mts` | 32 | Spike scaffolding: runs the original TypeScript `ground()` | |
| 35 | + |
| 36 | +Zero dependencies. No `package.json` under `skills/`, no `node_modules`, nothing added to the |
| 37 | +repository's dependency surface. The runner is Node 22's built-in `node --test`. |
| 38 | + |
| 39 | +### Finding 1: the port estimate was wrong by roughly a factor of two |
| 40 | + |
| 41 | +The issue predicted "roughly 155 lines" and flagged that estimate as something the spike would check. |
| 42 | +The gate came in at **322**. |
| 43 | + |
| 44 | +The estimate was not wrong about `grounding.ts`, which ported almost verbatim. It was wrong because |
| 45 | +it counted only the library. In the Lambda, `run.ts` and `cli.ts` supply answer extraction, the |
| 46 | +refusal wording, exit codes, and the corpus plumbing; a standalone script has to carry those itself. |
| 47 | +The lesson generalizes: when estimating a port out of a service, count the entry point, not the |
| 48 | +function. |
| 49 | + |
| 50 | +## Finding 2: the port is behaviourally identical (the headline) |
| 51 | + |
| 52 | +`golden.json` could not be used here. It holds nine `{question, expect}` fixtures and records no |
| 53 | +model answers, so there is nothing to replay through a gate. The harness generates cases from the |
| 54 | +real corpus instead, with a fixed seed, and runs each through both implementations, comparing the |
| 55 | +entire `GroundingResult`: status, sources, all five counts, and every per-citation flag. |
| 56 | + |
| 57 | +| Seed | Cases | `answered` | `no_match` | `low_confidence` | Disagreements | |
| 58 | +|---|---|---|---|---|---| |
| 59 | +| 20260728 | 5000 | 2921 | 995 | 1084 | **0** | |
| 60 | +| 1 | 5000 | 2896 | 1015 | 1089 | **0** | |
| 61 | +| 99991 | 5000 | 2872 | 1020 | 1108 | **0** | |
| 62 | +| 424242 | 5000 | 2924 | 1004 | 1072 | **0** | |
| 63 | + |
| 64 | +20,000 generated cases, zero disagreements. The generator mutates both quotes (verbatim, rewrapped, |
| 65 | +unicode-swapped, markdown-stripped, typo'd, truncated, blank, fabricated) and paths (`docs/` prefix, |
| 66 | +`./` prefix, `#anchor`, `:12` and `:12:40` suffixes, padding, fabricated), and deliberately cites the |
| 67 | +same document twice so the per-document gate is exercised rather than assumed. |
| 68 | + |
| 69 | +**The harness was verified to fail.** A passing differential test proves nothing unless it can |
| 70 | +detect a wrong port, so the exact semantic error the original's comments warn about was injected: |
| 71 | +per-quote gating (`quotesVerified < citationsTotal`) in place of per-document gating |
| 72 | +(`distinctDocs < docsCited`). The sweep caught **53 of 500** cases and the unit suite caught one. |
| 73 | +Both were restored to green afterward. |
| 74 | + |
| 75 | +Reproduce: |
| 76 | + |
| 77 | +```bash |
| 78 | +node --test skills/answer-strata-question/bin/differential.test.mjs |
| 79 | +CASES=5000 SEED=1 node --test skills/answer-strata-question/bin/differential.test.mjs |
| 80 | +``` |
| 81 | + |
| 82 | +## Finding 3: candidate selection narrows the corpus, and refuses some questions for free |
| 83 | + |
| 84 | +`graph.json` indexes 56 documents, against 135 markdown files under `docs/`. Selecting from the graph |
| 85 | +therefore excludes `.verification`, `.curation`, and `superpowers` **by data structure**, which is the |
| 86 | +same exclusion `.dockerignore` performs for the image and for the same stated reason: extra search |
| 87 | +space is the main cost driver, and a design spec must never be cited as a source. |
| 88 | + |
| 89 | +Measured across the nine golden questions: |
| 90 | + |
| 91 | +| | Lexical matches of 56 | At `--limit 8` | |
| 92 | +|---|---|---| |
| 93 | +| Range | 0 to 28 | 0 to 8 | |
| 94 | +| Mean | 17.0 | 7.1 | |
| 95 | +| Corpus excluded | 70 percent | 87 percent | |
| 96 | + |
| 97 | +One result is worth calling out. "What is the best pizza topping?" scores **zero** candidates, so the |
| 98 | +skill refuses it before reading a document or composing an answer. That refusal costs nothing. The |
| 99 | +other three refusal fixtures are topically adjacent to real documents ("the production database |
| 100 | +password for OSCER" matches the OSCER docs on every word that matters) and still score 8 candidates, |
| 101 | +so they must go to the model and be caught by the gate. Cheap refusals are available only for |
| 102 | +questions that are off-corpus, not for questions that are merely unanswerable. |
| 103 | + |
| 104 | +**Selection cannot cause a wrong answer.** It decides only what gets read; `verify-answer.mjs` |
| 105 | +independently checks every quote against the same graph. A selector that misses the right document |
| 106 | +costs recall, never soundness. That asymmetry is what makes a plain lexical scorer acceptable. |
| 107 | + |
| 108 | +**Recall is unmeasured.** The top-ranked candidate is topically correct for all five answerable |
| 109 | +fixtures, and the fixture about the CLI wrapping Copier ranks "How install and update work (Copier |
| 110 | +wrapper)" fourth, which is encouraging. But whether the model can actually answer from the top eight |
| 111 | +needs a live run, and this spike could not do one. |
| 112 | + |
| 113 | +## Finding 4: control inversion, and what it actually costs |
| 114 | + |
| 115 | +In the Lambda, trusted code wraps the untrusted model. In a skill, the agent calls the trusted code. |
| 116 | +That inversion cannot be closed by better JavaScript. It is not closable by the available levers |
| 117 | +either: auto mode allow rules permit actions rather than compelling them, and a repository hook |
| 118 | +cannot gate the Slack post, because posting is the harness's own output channel and not a tool call |
| 119 | +the repository intercepts. |
| 120 | + |
| 121 | +The mitigation built here has two halves: |
| 122 | + |
| 123 | +1. **The gate is the sole producer of the final message.** `verify-answer.mjs` prints the Slack text |
| 124 | + itself. An agent that skips it has no formatted output to post, so skipping is not a shortcut, it |
| 125 | + is a visible departure from the procedure. |
| 126 | +2. **Every message carries a verdict line** (`✅ 3/3 quotes verified · 2 docs`). A message without one |
| 127 | + is legibly off-script to any human reading the channel. |
| 128 | + |
| 129 | +This converts prevention into detection. That is a genuine downgrade and should be named as one. It |
| 130 | +is also worth being precise about the blast radius: the answer text is released only on `answered`, |
| 131 | +exactly as `run.ts` does it, and a test asserts that `low_confidence` and `no_match` never leak the |
| 132 | +proposed answer. So the failure mode of a skipped gate is an unverified answer posted with no verdict |
| 133 | +line, not a silently corrupted one. |
| 134 | + |
| 135 | +## Finding 5: a skill cannot ship an executable and its tests in one directory |
| 136 | + |
| 137 | +`node --test <dir>` fails on `bin/`, because directory mode executes `verify-answer.mjs` itself; the |
| 138 | +CLI guard fires on empty stdin and exits 6. The glob form works: |
| 139 | + |
| 140 | +```bash |
| 141 | +node --test "skills/answer-strata-question/bin/*.test.mjs" |
| 142 | +``` |
| 143 | + |
| 144 | +Minor, but it belongs in any CI wiring, and it is the kind of thing that silently disables a test |
| 145 | +suite if nobody notices the runner was passing a directory. |
| 146 | + |
| 147 | +## Recommendation on the duplication |
| 148 | + |
| 149 | +`ground()` now exists twice: `strata-qa/src/grounding.ts` and the ported copy. Resolving that was out |
| 150 | +of scope, and the differential harness makes the duplication safe for now by failing the moment the |
| 151 | +two diverge. If this direction is adopted, the follow-on story should make the `.mjs` the single |
| 152 | +source of truth and have the TypeScript import it, rather than the reverse. The port is dependency |
| 153 | +free and the TypeScript is not, so the dependency should point toward the simpler artifact. |
| 154 | + |
| 155 | +If this direction is not adopted, delete `skills/answer-strata-question/` entirely rather than leaving |
| 156 | +it as a second implementation nobody runs. |
| 157 | + |
| 158 | +## What this spike could not answer |
| 159 | + |
| 160 | +Each of these needs a live Claude Tag workspace and a Team or Enterprise entitlement: |
| 161 | + |
| 162 | +- **Whether Tag reliably invokes the gate.** The central risk, and the reason this was a spike. |
| 163 | +- **Whether the `Workflow` tool exists in Tag sandboxes.** `generate-strata-docs` commits to it with |
| 164 | + no fallback, so this matters beyond the present question. |
| 165 | +- **End to end answer quality** against the nine fixtures, and therefore whether the eight-candidate |
| 166 | + cap costs recall. |
| 167 | +- **Real per-question cost and latency.** |
| 168 | +- **Whether Agent Proxy can sign a request to an IAM-authed Lambda Function URL**, which is the gate |
| 169 | + on the competing option, not on this one. |
| 170 | + |
| 171 | +Confirming the plan entitlement is the cheapest next step and blocks the other four. |
| 172 | + |
| 173 | +## Where this leaves the decision |
| 174 | + |
| 175 | +The evidence supports the narrow claim and not the broad one. Everything mechanical survives the move |
| 176 | +out of the Lambda intact, proven against 20,000 cases by a harness demonstrated to detect a wrong |
| 177 | +port. Corpus scoping comes out stronger than the Lambda's, because it derives from the graph rather |
| 178 | +than from a build-time exclusion list. Deployment surface, Cursor dependency, and corpus staleness all |
| 179 | +disappear. |
| 180 | + |
| 181 | +What does not survive is the certainty that the check runs at all, and no amount of further offline |
| 182 | +work will recover it. That is now a product judgment rather than an engineering one: whether a |
| 183 | +verifier that runs almost always, and whose absence is visible, is good enough for answers this |
| 184 | +audience will act on. If it is, this direction is ready for a piloted story and #40's transport should |
| 185 | +be replaced. If it is not, the Lambda keeps the gate in its control flow and the SigV4 connection |
| 186 | +question becomes the one worth spiking next. |
0 commit comments