* test(#2773): failing-first contract and premise tests for translated edge-probe input Locks the Step 5.5 contract that a response_language project must feed the edge probe an English translation of each requirement's text, and binds that advice to measured engine behavior: the same requirement classifies to zero shapes in Portuguese and to collection/adjacency/empty/ordering in English. Also pins the honest limit — the issue's own repro sentence classifies to [] in English too, so translation is necessary but not sufficient and the authored shapes override is the documented fallback. Red before the doc change; the assertions are all false today. Refs #2773 * fix(#2773): feed the spec-phase edge probe English-translated requirement text The shape cues in src/edge-probe.cts are English word-boundary regexes, so a project running with response_language set wrote its SPEC requirements into the Step 5.5 $REQS_JSON heredoc in that language, matched no cue, classified to zero shapes, and landed every row in the unclassified sentinel (#1110). The taxonomy contributed nothing and --auto left it all unresolved — the probe was a silent no-op for exactly the spec type it exists to harden. Step 5.5 now states that the $REQS_JSON payload is engine input rather than user-facing output, so the response_language rule does not govern it: each requirement's text carries a faithful English translation, the SPEC keeps its original language, and requirement ids are never translated or renumbered. The instruction sits before the heredoc on purpose — the downstream APPLICABLE=0 warning fires only when every requirement is unclassified, so a partly-classified non-English spec would otherwise slip through with no signal at all. Measured against the compiled engine: the same requirement returns [] in Portuguese and collection -> adjacency/empty/ordering in English. Also measured: the issue's own repro sentence returns [] in English too, so translation is necessary but not sufficient — the instruction therefore points at the authored shapes override for prose carrying no cue in any language rather than promising that translation restores classification. Doc scope only, per the triage disposition on the issue. The compiled engine is untouched; the lang-hint / per-language cue-set fix is a separate follow-up. Closes #2773 * fix(#2773): clean up the edge-probe temp file on the placeholder-guard exit path Surfaced by the isolated security review of this branch. Between the mktemp and the unconditional cleanup, Step 5.5 has two sibling guards that disagreed about their own invariant: the engine-failure guard runs rm -f "$REQS_JSON" before exiting, while the empty/placeholder guard directly above it exited without one. A spec run that tripped the placeholder check therefore stranded a temp file holding the SPEC's requirement text in TMPDIR, once per failed run. The added contract test walks the region between the mktemp and the unconditional cleanup and asserts no exit path leaves the file behind, so the two guards can no longer drift apart. Proven to bind: run against the pre-fix file the walker reports the leaking exit; against the fixed file it reports none. Refs #2773 * docs(#2773): record the edge probe's English-cue input constraint in the predicate store The co-change gate flagged CONTEXT.md (13 co-changes with spec-phase.md) and docs/CONFIGURATION.md (11) as candidate-missing-updates, and both were real gaps rather than incidental coupling. CONTEXT.md's EdgeCompletenessProbeModule entry documents the input contract for classifyShape but did not record that SHAPE_CUES are English word-boundary patterns — so the predicate store implied text was language-agnostic, which is what a future agent reads before touching this seam. docs/CONFIGURATION.md's response_language row is what a non-English project reads when it turns the setting on; it now names the one deliberate exception and links to the FEATURES.md explanation, so the interaction is discoverable from the config key rather than only from the workflow. CONTEXT-INDEX.json regenerated via gen-context-index.cjs --write. The drift-ack fragment is updated for the final byte range and now also records the placeholder-guard cleanup fix folded into the same block. Refs #2773 * fix(#2773): append the growth rationale to the existing spec-phase.md ack entry The remote runner caught this: emitted-attribution.test.cjs pins the 0000-legacy-migration.json spec-phase.md entry permanently (the #2914 migration regression test asserts the exact '31987 -> 31997' delta text survives), so removing it to avoid a duplicate-key collision with a new fragment broke that test instead of satisfying the ratchet. The entry is an accreting log, not a single-use slot — #2733, #3132 and #3102 were each appended to the same reason string by later PRs, which is how a shared growth key coexists with the rule that two ack sources may never name the same path. This appends the #2773 rationale the same way and drops the separate fragment, whose spec-phase.md key was the collision. Verified locally by reproducing both affected tests against the real fragment before re-dispatching: the pinned delta survives, grown[0].acked is true, staleAcks is empty, and all 35 entries still read as spent. Refs #2773 * docs(#2773): add a how-to for probing edges in a non-English project The phase gate's enablementSequence check caught a wrong call of mine. I had recorded that no how-to was owed because the user takes zero extra steps — the workflow translates the probe input itself. Written out, though, the sequence from off to value is two steps and step 1 depends on response_language, a setting owned by a different capability than the edge probe, which is exactly the condition the how-to test names. There is also real task content a reference table cannot carry: the three-way split between a few unclassified rows (the classifier's recall gap), every row unclassified (the probe could not read the spec at all), and the silent partly-classified case where the APPLICABLE=0 warning never fires. That last one is what a user would otherwise misread as a clean bill of health. Shaped after the resolve-edge-coverage-findings / resolve-unreachable-guard siblings and indexed from docs/README.md next to its closest relative. Refs #2773 * chore(#2773): backfill the changeset PR number pr:0 placeholder replaced with the real PR number now that #3713 exists. Refs #2773 --------- Co-authored-by: sim <sim@local>
18 KiB
Edge-Probe — Spec-Completeness Reference
Shared reference for the spec/requirements phase. Companion to
@~/.claude/gsd-core/references/domain-probes.md: domain-probes covers the
technology axis (auth, search, caching, deployment); this covers the
data/behavior-shape axis (boundaries, adjacency, encoding, ordering…). Walk each
requirement against the closed taxonomy below, propose a concrete candidate edge for each
applicable category, and resolve each to exactly one state. Adopt the established QA names
verbatim — this is decades-old black-box test technique, moved upstream to the spec layer.
This doc is written in generic requirements → checks → verifier terms with no
tool-specific vocabulary, so it is portable: copy it into any spec/requirements process.
A short mapping table at the end binds it to common host structures.
Why front-of-pipeline
A goal-backward verifier only checks assertions that exist; an assertion only exists for a requirement that was written down. A domain-boundary edge the author never surfaced is invisible to the verifier — and worse, the verifier is confidently wrong about it (measured: ~0.93 confidence while catching 0/12 omitted-edge defects, an expected calibration error of 0.81 — worse than a coin flip — versus 100% / 0.03 on edges the spec did state). The fix is not a better verifier or a confidence gate; confidence is uninformative across these regimes. The fix is spec completeness: surface the omitted edge into an explicit, checkable assertion before any code exists, after which the verifier reliably catches it.
The underlying techniques are classic and should be named as such — Boundary Value Analysis, Equivalence Partitioning, the Category-Partition method, and Metamorphic Relations; property-based testing (PBT) is the academic name for the held-out backstop. The literature applies these at the test layer (back of pipeline, generating checks). The differentiated move here is placement, not technique: apply the same edge taxonomy at the spec layer (front of pipeline, generating requirements), with the explicit goal of extending a verifier's reach. Recent LLM spec work corroborates the core finding that the spec layer is the measured weak point:
- SLD-Spec — program slicing + logical deletion (code→spec): arXiv 2509.09917
- Specine / AutoReSpec / SpecMind — spec alignment & postcondition inference
- CodeSpecBench (2604.12268), OSVBench (2504.20964), VERINA (2505.23135) — benchmarks showing models solve tasks far better than they generate precise behavioral specs
- LLM property-based tests for edge cases: arXiv 2510.25297 (PBT+EBT ≈ 81% edge detection)
Inputs
A list of requirements, each a { id, text, shapes? } record where text is a testable
statement and shapes is an optional author-supplied override of the data/behavior shape.
The five shapes are: numeric-range, collection, text, stateful, io. When
shapes is absent, a heuristic classifier proposes them from the requirement prose
(propose-then-confirm) — the author may correct the shape.
text is English, whatever language the SPEC is in. The heuristic cues are English
word-boundary patterns, so a project running with response_language set must pass text as a
faithful English translation of the requirement; the SPEC itself keeps the original language and
the id is never translated. Prose in another language matches no cue, classifies to zero
shapes, and surfaces as unclassified (#1110) — the probe contributes nothing. Where a
requirement carries no cue even in English, author shapes explicitly rather than leaning on
the classifier.
Taxonomy (8 categories)
Closed and small by design: a fixed eight the author must explicitly clear beats thirty nobody finishes. The failure mode being eliminated was never "too few categories" — it was that no taxonomy was systematically applied at all. Categories 1, 2, and 4 alone cover the three canonical corpus defects (banker's-rounding ties, touching intervals, grapheme truncation). Growth happens via optional domain packs, not by bloating the core.
| id | name (QA term) | applies to shapes | probe question |
|---|---|---|---|
| boundary | Boundary values | numeric-range | What happens exactly at each min/max/threshold — and one step either side? |
| adjacency | Adjacency / touching | collection | When two things are exactly equal or just touch, do they merge, collide, or separate? |
| empty | Empty / degenerate | collection, text | What is the result for empty, single-element, or null input? |
| encoding | Encoding / representation | text | Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form? |
| ordering | Ordering / stability | collection | When elements compare equal, is output order specified and stable? |
| precision | Precision / overflow | numeric-range | Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)? |
| idempotency | Idempotency / repetition | stateful | What happens if this runs twice on the same input? |
| concurrency | Concurrency / effect ordering | stateful, io | If interrupted or run in parallel, what is guaranteed? |
Relevance filter + resolution states
Two rules keep the probe honest and prevent an "everything is N/A" failure mode:
- Relevance filter first. Classify each requirement's shape, then raise only the
categories whose
applies to shapesintersect that requirement's shapes. A pure-text requirement is never asked about overflow. This is what makes an unresolved edge meaningful: it is an edge that applies and was not addressed. - Dismissal requires a reason string. "N/A — input is a bounded enum, no boundary exists" is valid; silence is not. The reason string is the audit trail.
Zero-classification surfaces an unclassified candidate (#1110). The relevance filter is
a heuristic over prose cues, so a requirement whose wording is edge-relevant but matches no
shape cue would otherwise classify to zero shapes → zero edges and vanish from coverage with
no signal — the same silent blind spot the probe exists to catch. Instead, a requirement with
non-empty prose, no authored shapes, and zero matched shapes surfaces exactly one soft
unclassified — review manually candidate (category: "unclassified", status: "unresolved").
It is a dismissible nudge — resolve it, or dismiss it with a reason (e.g. a genuinely edge-free
static-asset requirement) — never a hard block. unclassified is a review signal, not a
ninth taxonomy category: the closed eight above are unchanged, and an explicit shapes: []
opt-out stays silent (the author's deliberate "no edge surface").
Each raised edge carries two orthogonal axes — a resolution lifecycle and, when resolved, a verification tier (ADR-550 Decision 7, the shared probe-core model):
- status —
resolved | dismissed | unresolved:- resolved — the edge is addressed; how it is addressed is the verification tier.
- dismissed — not applicable, accompanied by a required, non-empty reason string.
- unresolved — carried forward and flagged; the author chose not to resolve it yet.
- verification (only when
statusisresolved;nullotherwise) —explicit | backstop:- explicit — a checkable assertion for the edge is written (a SPEC acceptance criterion).
- backstop — a held-out / property-based test stands in for an edge the author knows but cannot fully articulate in prose (records intent; the test body is authored later).
Splitting these axes keeps the lifecycle enum free of a verification fact (the old single
enum smuggled covered/backstop — both resolved — into one flat list) and lets sibling
probes add their own verification tiers (e.g. test | judgment) without a parallel enum.
coverage.resolved is the count of closed edges — resolved + dismissed (the
pre-re-cut "covered + dismissed + backstop" set, count-preserved). An unresolved
applicable edge is the precise signal a soft completeness gate raises.
Output schema
The probe emits, per edge, an item of the form:
{ requirement_id, category, status, verification, resolution, reason, probe }
plus a coverage summary:
coverage: { applicable, resolved, unresolved, byVerification: { explicit, backstop } }
applicable is the number of raised edges, resolved = closed (resolved + dismissed)
status edges, unresolved is the remainder, and byVerification breaks the
resolved-status edges down by tier (probe-agnostic in core; the edge adapter declares
{ explicit, backstop }). This JSON is the stable contract both the reference
implementation and any third-party port emit.
Generic mapping (requirements → checks → verifier)
| Host structure | "requirement" | a resolved/explicit edge becomes |
a resolved/backstop edge becomes |
|---|---|---|---|
| GSD SPEC | a SPEC Requirement | an Acceptance Criterion that plan-phase lifts into must_haves.truths |
a non-inferable check in must_haves.truths (needs a held-out/PBT test) |
| Gherkin feature | a Scenario | an additional Then assertion / Scenario Outline row |
a tagged scenario routed to a property test |
| OpenAPI operation | an operation | a response/constraint example + schema rule | a contract/property test on the operation |
| Docstring contract | a documented behavior | an assertion in the contract test | a property-based test for the function |
The portable invariant: a resolved/explicit edge produces the unit your verifier
iterates over (GSD: a must_haves.truth); a resolved/backstop edge produces a test
added to that same set as a non-inferable check. An unresolved edge is an explicit
assumption the downstream planner must surface, not silently drop.
Worked example (merge-intervals)
Given a single requirement with no resolutions yet:
[{ "id": "R1", "text": "Merge a list of overlapping intervals into the minimal set" }]
the requirement classifies as a collection, which raises adjacency, empty, and
ordering (but not boundary/precision/encoding/idempotency/concurrency). With no
resolutions supplied, every applicable edge is unresolved:
{
"items": [
{ "requirement_id": "R1", "category": "adjacency", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" },
{ "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" },
{ "requirement_id": "R1", "category": "ordering", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When elements compare equal, is output order specified and stable?" }
],
"coverage": { "applicable": 3, "resolved": 0, "unresolved": 3, "byVerification": { "explicit": 0, "backstop": 0 } }
}
The adjacency row is the one that catches the canonical defect: [[1,2],[2,3]] intervals
that only touch — does the spec say they merge? Resolving it resolved/explicit writes
that assertion, and the verifier can then enforce it. This worked-example block is kept
byte-for-byte (parsed-JSON) identical to its fixture by edge-probe-docs-fixtures.test.cjs,
so the doc and the reference implementation cannot silently drift.
Worked example (round-half-even)
Given a requirement to round floating-point values using the banker's-rounding (half-even)
rule, the requirement classifies as numeric-range, which raises boundary and precision
(but not adjacency/empty/ordering/encoding/idempotency/concurrency):
{
"items": [
{ "requirement_id": "R1", "category": "boundary", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What happens exactly at each min/max/threshold — and one step either side?" },
{ "requirement_id": "R1", "category": "precision", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" }
],
"coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } }
}
The precision row surfaces the canonical defect for this requirement: IEEE 754 floating-point
arithmetic rounds 2.5 to 2 (not 3) under half-even — the probe asks whether the spec
states that contract explicitly, so the verifier can enforce it.
Worked example (truncate-graphemes)
Given a requirement to truncate a string to N grapheme clusters (not bytes or code points),
the requirement classifies as text, which raises empty and encoding:
{
"items": [
{ "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" },
{ "requirement_id": "R1", "category": "encoding", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form?" }
],
"coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } }
}
The encoding row is the load-bearing one: a requirement that says "truncate to 10
characters" is ambiguous — the spec must state whether "character" means bytes, UTF-16
code units, Unicode code points, or grapheme clusters (emoji sequences are 1 grapheme,
multiple code points).
Worked example (money-rounding)
Given a requirement to round monetary amounts to two decimal places, the requirement
classifies as numeric-range, which raises boundary and precision:
{
"items": [
{ "requirement_id": "R1", "category": "boundary", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What happens exactly at each min/max/threshold — and one step either side?" },
{ "requirement_id": "R1", "category": "precision", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?" }
],
"coverage": { "applicable": 2, "resolved": 0, "unresolved": 2, "byVerification": { "explicit": 0, "backstop": 0 } }
}
The boundary row asks what the minimum and maximum representable values are (negative
amounts? fractional cents?). The precision row asks whether IEEE 754 binary rounding
can produce $0.30000000000000004 — the spec must commit to a representation.
Worked example (list-dedupe)
Given a requirement to deduplicate a list of items, the requirement classifies as
collection, which raises adjacency, empty, and ordering:
{
"items": [
{ "requirement_id": "R1", "category": "adjacency", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" },
{ "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" },
{ "requirement_id": "R1", "category": "ordering", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "When elements compare equal, is output order specified and stable?" }
],
"coverage": { "applicable": 3, "resolved": 0, "unresolved": 3, "byVerification": { "explicit": 0, "backstop": 0 } }
}
The ordering row is the non-obvious one: dedupe removes duplicates, but which copy is
kept — first occurrence, last, or implementation-defined? The spec must commit.
Worked example (resolved-mixed)
The same merge-intervals requirement after a resolution session where adjacency was
resolved with an explicit acceptance criterion, ordering was dismissed, and empty was
left unresolved:
{
"items": [
{ "requirement_id": "R1", "category": "adjacency", "status": "resolved", "verification": "explicit", "resolution": "AC#6: touching intervals merge", "reason": null, "probe": "When two things are exactly equal or just touch, do they merge, collide, or separate?" },
{ "requirement_id": "R1", "category": "empty", "status": "unresolved", "verification": null, "resolution": null, "reason": null, "probe": "What is the result for empty, single-element, or null input?" },
{ "requirement_id": "R1", "category": "ordering", "status": "dismissed", "verification": null, "resolution": null, "reason": "output is canonically sorted; no tie possible", "probe": "When elements compare equal, is output order specified and stable?" }
],
"coverage": { "applicable": 3, "resolved": 2, "unresolved": 1, "byVerification": { "explicit": 1, "backstop": 0 } }
}
coverage.resolved is 2 — the closed set (adjacency=resolved/explicit + ordering=dismissed);
unresolved is 1 (empty); byVerification.explicit is 1 (the single explicitly-verified
edge), backstop 0. The soft gate raises on this example because one applicable edge remains
unresolved — the author must either specify, dismiss, or backstop it before writing the SPEC.