PHON-218: Unified Constraint Model + Worker-Owned Compiler Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: One constraint model every tool can express (categorical / flag / scope variants), compiled in exactly one place (the Worker), with the default vocabulary scope defined once and served through metadata. Zero visual change.
Architecture: The frontend's Constraint[] union gains categorical, flag, and scope variants; the Worker mirrors the union and its constraintsToBody adapter learns the new variants plus the contrastive→pattern derivation that currently lives frontend-side. /api/words/search starts accepting constraints[] (legacy WordSearchBody fields still work). DEFAULT_SCOPE_EXCLUSIONS replaces the hardcoded register clause in wordFilter.ts and is served via /api/property-metadata. Frontend buildWordSearchRequest stops flattening constraints into legacy fields and just serializes.
Tech Stack: TypeScript. Worker: Hono + vitest (plain unit tests import libs directly; route tests use cloudflare:test + SELF.fetch). Frontend: vitest.
Spec: docs/superpowers/specs/2026-08-14-constraint-ui-unification-design.md
Global Constraints¶
- Branch: work happens on
feat/phon-217-constraint-ui-unification(already pushed; contains the spec). PR targetsdevelop. - Zero visual change in this PR. Builder, useSampleWords, and packSeeder keep their exact call signatures (
buildWordSearchRequest(constraints, opts, limit)with unchangedWordOptions). - D1 bind-param limit is 100/query — the scope bundle adds at most 6 bound values, fine, but never interpolate values into SQL; always bind.
- Default scope bundle (spec Section A, verbatim):
specialization IN ('term_of_art', 'nomenclature'),currency != 'current'(bound as NOT IN('historicism', 'archaic', 'obsolete')— the axis's documented value set; only historicism/archaic exist in the 2026-08-13 data, obsolete is bound defensively per the axis definition inproperties.ts),nativization = 'unassimilated'. - Terminology: "feature vectors", never "embeddings" (not expected to arise here, but binding).
- Before every push:
cd packages/web/workers && npx tsc --noEmit && npm testandcd packages/web/frontend && npx tsc --noEmit && npm run lint && npm test && npm run build(CI runs these; build needs no env var in dev serve mode but CI build setsVITE_API_URL). - Commit after every task with the exact messages given; end commit bodies with
Co-Authored-By: Claude Fable 5 <[email protected]>.
Task 1: Worker Constraint union + new constraintsToBody variants¶
Files:
- Modify: packages/web/workers/src/types.ts (append after WordSearchBody, ~line 247)
- Modify: packages/web/workers/src/lib/constraintsToBody.ts
- Modify: packages/web/workers/src/config/properties.ts (append after CATEGORICAL_WORD_PROPERTIES, line 543)
- Test: packages/web/workers/src/__tests__/constraintsToBody.test.ts (create)
Interfaces:
- Consumes: existing WordSearchBody, PatternRule, CATEGORICAL_WORD_PROPERTIES (a Set<string> containing specialization, currency, nativization, basic_level).
- Produces: worker-side Constraint union type (exported from types.ts); BOOLEAN_WORD_FLAGS: Set<string> exported from config/properties.ts; constraintsToBody handling categorical / flag / scope. Task 2 relies on the ${property}_exclude body-key convention introduced here; Task 3 extends this same function.
- [ ] Step 1: Write the failing test
Create packages/web/workers/src/__tests__/constraintsToBody.test.ts:
/**
* constraintsToBody — new constraint variants (PHON-218).
* categorical → IN-list body keys (or `${property}_exclude` for mode=exclude),
* flag → boolean body keys (whitelisted via BOOLEAN_WORD_FLAGS),
* scope → lemmas_only / include_specialized.
*/
import { describe, it, expect } from 'vitest';
import { constraintsToBody } from '../lib/constraintsToBody';
import type { Constraint, WordSearchBody } from '../types';
const base: WordSearchBody = {};
describe('constraintsToBody: categorical', () => {
it('include mode sets the property IN-list on the body', () => {
const out = constraintsToBody(
[{ type: 'categorical', property: 'specialization', values: ['term_of_art'] }] as Constraint[],
base,
);
expect(out.specialization).toEqual(['term_of_art']);
});
it('exclude mode sets `${property}_exclude`', () => {
const out = constraintsToBody(
[{ type: 'categorical', property: 'currency', values: ['archaic'], mode: 'exclude' }] as Constraint[],
base,
);
expect((out as Record<string, unknown>).currency_exclude).toEqual(['archaic']);
expect(out.currency).toBeUndefined();
});
it('unknown property is dropped (never reaches the body)', () => {
const out = constraintsToBody(
[{ type: 'categorical', property: 'pos; DROP TABLE', values: ['x'] }] as Constraint[],
base,
);
expect((out as Record<string, unknown>)['pos; DROP TABLE']).toBeUndefined();
});
it('empty values list is dropped', () => {
const out = constraintsToBody(
[{ type: 'categorical', property: 'specialization', values: [] }] as Constraint[],
base,
);
expect(out.specialization).toBeUndefined();
});
});
describe('constraintsToBody: flag', () => {
it('has_image true sets body.has_image', () => {
const out = constraintsToBody(
[{ type: 'flag', property: 'has_image', value: true }] as Constraint[],
base,
);
expect(out.has_image).toBe(true);
});
it('has_image false is a no-op (the switch is not tri-state)', () => {
const out = constraintsToBody(
[{ type: 'flag', property: 'has_image', value: false }] as Constraint[],
base,
);
expect(out.has_image).toBeUndefined();
});
it('non-whitelisted flag property is dropped', () => {
const out = constraintsToBody(
[{ type: 'flag', property: 'is_canonical', value: true }] as Constraint[],
base,
);
expect((out as Record<string, unknown>).is_canonical).toBeUndefined();
});
});
describe('constraintsToBody: scope', () => {
it('lemmas_only', () => {
const out = constraintsToBody([{ type: 'scope', kind: 'lemmas_only' }] as Constraint[], base);
expect(out.lemmas_only).toBe(true);
});
it('include_specialized', () => {
const out = constraintsToBody(
[{ type: 'scope', kind: 'include_specialized' }] as Constraint[],
base,
);
expect(out.include_specialized).toBe(true);
});
});
describe('constraintsToBody: existing behavior unchanged', () => {
it('bound + pattern + cv_shape still map as before', () => {
const out = constraintsToBody(
[
{ type: 'bound', norm: 'aoa', max_value: 5 },
{ type: 'pattern', pattern_type: 'STARTS_WITH', phonemes: ['s'], mode: 'include' },
{ type: 'cv_shape', shapes: ['CVC'] },
] as Constraint[],
base,
);
expect(out.filters).toEqual({ max_aoa: 5 });
expect(out.patterns).toEqual([{ type: 'STARTS_WITH', phoneme: 's', mode: 'include' }]);
expect(out.cv_shape).toEqual(['CVC']);
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd packages/web/workers && npx vitest run src/__tests__/constraintsToBody.test.ts
Expected: FAIL — type errors on the new variants and/or the categorical/flag/scope cases fall through to default: and set nothing.
- [ ] Step 3: Add the worker
Constraintunion totypes.ts
Append after the WordSearchBody interface (after line 247), and add constraints?: Constraint[]; inside WordSearchBody (next to patterns):
// ============================================================================
// Constraint union (PHON-218) — mirrors frontend types/governance.ts.
// The frontend serializes Constraint[] and the Worker compiles it; keep the
// two unions in sync by hand (no shared package between the two builds).
// ============================================================================
export interface BoundConstraint {
type: 'bound';
norm: string;
min_value?: number;
max_value?: number;
}
export interface PatternConstraint {
type: 'pattern';
pattern_type: PatternRule['type'];
phonemes: string[];
mode?: ConstraintMode;
}
export interface CvShapeConstraint {
type: 'cv_shape';
shapes: string[];
}
export interface MinpairConstraint {
type: 'contrastive_minpair';
phoneme1: string;
phoneme2: string;
position?: 'initial' | 'medial' | 'final' | 'any';
}
export interface MaxoppConstraint {
type: 'contrastive_maxopp';
phoneme1: string;
phoneme2: string;
position?: 'initial' | 'medial' | 'final' | 'any';
min_sonorant_diff?: number;
}
export interface MultoppConstraint {
type: 'contrastive_multopp';
substitute: string;
targets: string[];
n_targets?: number;
position?: 'initial' | 'medial' | 'final' | 'any';
}
/** Narrow a categorical words column (register axes, basic_level) to an
* IN-list (include) or its complement (exclude). Property ids are validated
* against CATEGORICAL_WORD_PROPERTIES before reaching SQL. */
export interface CategoricalConstraint {
type: 'categorical';
property: string;
values: string[];
mode?: ConstraintMode;
}
/** Boolean words-column toggle (has_image today). Property ids are validated
* against BOOLEAN_WORD_FLAGS. value:false is a no-op — the UI switches are
* "restrict to", not tri-state. */
export interface FlagConstraint {
type: 'flag';
property: string;
value: boolean;
}
/** Vocabulary-universe restrictions that are not per-property filters. */
export interface ScopeConstraint {
type: 'scope';
kind: 'lemmas_only' | 'include_specialized';
}
export type Constraint =
| BoundConstraint
| PatternConstraint
| CvShapeConstraint
| MinpairConstraint
| MaxoppConstraint
| MultoppConstraint
| CategoricalConstraint
| FlagConstraint
| ScopeConstraint;
- [ ] Step 4: Add
BOOLEAN_WORD_FLAGStoconfig/properties.ts
Append directly after CATEGORICAL_WORD_PROPERTIES (line 543):
/**
* Boolean words-table columns reachable through a `flag` constraint.
* Kept as an explicit allowlist for the same reason CATEGORICAL_WORD_PROPERTIES
* exists: constraint property ids become SQL column references.
*/
export const BOOLEAN_WORD_FLAGS = new Set(['has_image']);
- [ ] Step 5: Implement the new variants in
constraintsToBody.ts
Replace the local ConstraintLike type with the real union and add the three cases. The whole file becomes:
/**
* constraintsToBody — translate the frontend's Constraint[] payload into the
* WordSearchBody shape that compileWordFilter consumes.
*
* PHON-218: this adapter is now the ONLY Constraint[] → body translation —
* /api/words/search and /api/sentences both run it Worker-side. Contrastive
* constraints are pair-graph predicates: they are dropped here by default
* (sentence retrieval handles them as pair witnesses, not word filters).
*/
import type { WordSearchBody, PatternRule, Constraint } from '../types';
import { CATEGORICAL_WORD_PROPERTIES, BOOLEAN_WORD_FLAGS } from '../config/properties';
export function constraintsToBody(
constraints: Constraint[] | undefined,
base: WordSearchBody,
): WordSearchBody {
if (!constraints?.length) return base;
const merged: WordSearchBody = { ...base };
const patterns: (PatternRule | string)[] = [...(base.patterns ?? [])];
const filters: Record<string, number | null> = { ...base.filters };
for (const c of constraints) {
switch (c.type) {
case 'bound': {
if (typeof c.min_value === 'number') filters[`min_${c.norm}`] = c.min_value;
if (typeof c.max_value === 'number') filters[`max_${c.norm}`] = c.max_value;
break;
}
case 'pattern': {
// One Pattern constraint per UI entry with a phonemes[] array;
// PatternRule takes a single phoneme string (space-separated for
// multi-phoneme sequences), so collapse here.
patterns.push({
type: c.pattern_type,
phoneme: c.phonemes.join(' '),
mode: c.mode,
});
break;
}
case 'cv_shape': {
if (Array.isArray(c.shapes) && c.shapes.length) {
merged.cv_shape = [...(merged.cv_shape ?? []), ...c.shapes];
}
break;
}
case 'categorical': {
// Property ids become SQL column references — allowlist before the
// body, exactly as compileWordFilter re-validates after it.
if (!CATEGORICAL_WORD_PROPERTIES.has(c.property)) break;
const values = (c.values ?? []).filter(
(v) => typeof v === 'string' && v.length > 0 && v.length <= 64,
);
if (!values.length) break;
const key = c.mode === 'exclude' ? `${c.property}_exclude` : c.property;
(merged as Record<string, unknown>)[key] = values;
break;
}
case 'flag': {
if (BOOLEAN_WORD_FLAGS.has(c.property) && c.value === true) {
(merged as Record<string, unknown>)[c.property] = true;
}
break;
}
case 'scope': {
if (c.kind === 'lemmas_only') merged.lemmas_only = true;
else if (c.kind === 'include_specialized') merged.include_specialized = true;
break;
}
// contrastive_minpair / contrastive_maxopp / contrastive_multopp are
// pair-graph predicates — dropped for word filtering (Task 3 adds an
// opt-in pattern derivation for /api/words/search).
default:
break;
}
}
if (patterns.length) merged.patterns = patterns;
if (Object.keys(filters).length) merged.filters = filters;
return merged;
}
Note: sentences.ts:618 calls constraintsToBody(body.constraints, body) — the signature is unchanged, so it compiles as-is.
- [ ] Step 6: Run test to verify it passes
Run: cd packages/web/workers && npx vitest run src/__tests__/constraintsToBody.test.ts
Expected: PASS (all cases).
- [ ] Step 7: Type-check and run the full worker suite
Run: cd packages/web/workers && npx tsc --noEmit && npm test
Expected: clean. (ConstraintMode and PatternRule already exist in types.ts — the new interfaces reference them; if tsc flags a duplicate identifier, the frontend file was edited by mistake: only packages/web/workers/src/types.ts changes in this task.)
- [ ] Step 8: Commit
git add packages/web/workers/src/types.ts packages/web/workers/src/lib/constraintsToBody.ts packages/web/workers/src/config/properties.ts packages/web/workers/src/__tests__/constraintsToBody.test.ts
git commit -m "feat(phon-218): categorical/flag/scope constraint variants in the worker adapter"
Task 2: DEFAULT_SCOPE_EXCLUSIONS + full-bundle default scope + categorical exclude in wordFilter¶
Files:
- Modify: packages/web/workers/src/config/properties.ts (append after BOOLEAN_WORD_FLAGS from Task 1)
- Modify: packages/web/workers/src/lib/wordFilter.ts:107-138 (register default block) and :203-221 (categorical block)
- Test: packages/web/workers/src/__tests__/registerFilter.test.ts (modify — pinned strings change deliberately)
Interfaces:
- Consumes: CATEGORICAL_WORD_PROPERTIES, BOOLEAN_WORD_FLAGS (Task 1), the ${property}_exclude body-key convention (Task 1).
- Produces: DEFAULT_SCOPE_EXCLUSIONS: readonly ScopeExclusion[] and interface ScopeExclusion { property: string; values: readonly string[] } exported from config/properties.ts — Task 5 serves this object verbatim through /api/property-metadata. compileWordFilter behavior: (a) default scope = one NULL-safe bound NOT IN clause per bundle entry; (b) include_specialized: true drops the whole bundle; (c) an explicit include filter on a bundle property suppresses the default for that property only (an explicit exclude filter does not — it narrows further); (d) ${id}_exclude compiles to (w.id IS NULL OR w.id NOT IN (...)).
- [ ] Step 1: Update the pinned tests and add new ones
In registerFilter.test.ts, the two assertions pinning the literal-interpolated clause change (values are now bound), and the bundle grows. Replace the register default describe block with:
describe('default vocabulary scope (DEFAULT_SCOPE_EXCLUSIONS)', () => {
it('excludes the full bundle by default: specialization, currency, nativization', () => {
const compiled = compileWordFilter({});
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).toContain('w.specialization IS NULL OR w.specialization NOT IN (?, ?)');
expect(sql).toContain('w.currency IS NULL OR w.currency NOT IN (?, ?, ?)');
expect(sql).toContain('w.nativization IS NULL OR w.nativization NOT IN (?)');
expect(compiled.wordsParams).toEqual(
expect.arrayContaining(['term_of_art', 'nomenclature', 'historicism', 'archaic', 'obsolete', 'unassimilated']),
);
});
it('include_specialized drops the entire bundle', () => {
const compiled = compileWordFilter({ include_specialized: true });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).not.toContain('w.specialization IS NULL OR');
expect(sql).not.toContain('w.currency IS NULL OR');
expect(sql).not.toContain('w.nativization IS NULL OR');
});
it('an explicit include filter suppresses the default for that property ONLY', () => {
const compiled = compileWordFilter({ specialization: ['term_of_art'] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).toContain('w.specialization IN (?)');
expect(sql).not.toContain('w.specialization IS NULL OR');
// currency + nativization defaults still stand:
expect(sql).toContain('w.currency IS NULL OR w.currency NOT IN');
expect(sql).toContain('w.nativization IS NULL OR w.nativization NOT IN');
});
it('an explicit EXCLUDE filter does not suppress the default (it narrows further)', () => {
const compiled = compileWordFilter({ specialization_exclude: ['slang'] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).toContain('w.specialization IS NULL OR w.specialization NOT IN (?, ?)'); // default
expect(sql).toContain('(w.specialization IS NULL OR w.specialization NOT IN (?))'); // explicit
expect(compiled.wordsParams).toContain('slang');
});
it('categorical exclude compiles NULL-safe for any allowlisted property', () => {
const compiled = compileWordFilter({ basic_level_exclude: ['subordinate', 'superordinate'] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).toContain('(w.basic_level IS NULL OR w.basic_level NOT IN (?, ?))');
expect(compiled.wordsParams).toEqual(expect.arrayContaining(['subordinate', 'superordinate']));
});
it('binds every value rather than interpolating', () => {
const compiled = compileWordFilter({ specialization: ['term_of_art', 'slang'] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).toContain('w.specialization IN (?, ?)');
expect(compiled.wordsParams).toEqual(expect.arrayContaining(['term_of_art', 'slang']));
});
it('ignores an empty list rather than compiling IN ()', () => {
const compiled = compileWordFilter({ specialization: [] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).not.toContain('IN ()');
expect(sql).toContain('w.specialization IS NULL OR w.specialization NOT IN'); // default still applies
});
it('does not compile cv_shape as an equality IN-list', () => {
const compiled = compileWordFilter({ cv_shape: ['CVC'] });
const sql = compiled.wordsWhere.join(' AND ');
expect(sql).not.toContain('w.cv_shape IN');
expect(sql).toContain('cv_shapes LIKE');
});
});
- [ ] Step 2: Run to verify the new assertions fail
Run: cd packages/web/workers && npx vitest run src/__tests__/registerFilter.test.ts
Expected: FAIL — current code emits the interpolated two-value specialization clause only.
- [ ] Step 3: Add
DEFAULT_SCOPE_EXCLUSIONStoconfig/properties.ts
Append after BOOLEAN_WORD_FLAGS:
/**
* The default vocabulary universe (spec 2026-08-14, PHON-217/218). Word-list
* surfaces exclude these register/currency/nativization classes unless the
* user opts in (`include_specialized`) or filters a listed property
* explicitly. Served verbatim through /api/property-metadata so the UI
* tooltip enumerates this definition rather than restating it — the label
* can never drift from the SQL.
*
* `obsolete` is bound although the 2026-08-13 data contains none: it is part
* of the currency axis's documented value set and future revisions may emit it.
*/
export interface ScopeExclusion {
property: string;
values: readonly string[];
}
export const DEFAULT_SCOPE_EXCLUSIONS: readonly ScopeExclusion[] = [
{ property: 'specialization', values: ['term_of_art', 'nomenclature'] },
{ property: 'currency', values: ['historicism', 'archaic', 'obsolete'] },
{ property: 'nativization', values: ['unassimilated'] },
];
- [ ] Step 4: Rewrite the default-scope + categorical blocks in
wordFilter.ts
Import the new config (line 18 area):
import { CATEGORICAL_WORD_PROPERTIES, DEFAULT_SCOPE_EXCLUSIONS } from '../config/properties';
Add one helper above compileWordFilter (it generalizes the existing inline specRaw/specFilter logic — delete those lines):
/** True when the body carries a non-empty explicit include-filter for a
* categorical property (either at `filters.<id>` or the body root). An
* explicit include suppresses that property's default-scope exclusion —
* asking FOR term_of_art must not be intersected with a rule excluding it.
* An EMPTY list is not a filter (`[]` is truthy in JS; treating it as one
* is the aardvark bug this block's comment describes). */
function explicitCategoricalFilter(body: WordSearchBody, id: string): boolean {
const raw = (body.filters as Record<string, unknown> | undefined)?.[id]
?? (body as Record<string, unknown>)[id];
return Array.isArray(raw)
? raw.some((v) => typeof v === 'string' && v.length > 0)
: typeof raw === 'string' && raw.length > 0;
}
Replace the register-default block (wordFilter.ts:130-138 — the specRaw/specFilter/wantsAll lines and the NOT IN push; KEEP the explanatory comment block above it, amending its first line to say the default now covers the full DEFAULT_SCOPE_EXCLUSIONS bundle) with:
const wantsAll = body.include_specialized === true;
if (includeDefaultRegister && !wantsAll) {
for (const { property, values } of DEFAULT_SCOPE_EXCLUSIONS) {
if (explicitCategoricalFilter(body, property)) continue;
// NULL-safe: rows without axis coverage (non-canonical vocabulary on
// full-scope surfaces) must not be silently dropped by a NULL NOT IN.
wordsWhere.push(
`(w.${property} IS NULL OR w.${property} NOT IN (${values.map(() => '?').join(', ')}))`,
);
wordsParams.push(...values);
}
}
Ordering caveat: the current block pushes into wordsWhere before wordsParams exists (params array is declared at line 162, after the register block). Move the const wordsParams/propsParams/pctParams declarations (lines 162-164, with their comment) UP to directly under the wordsWhere declaration (line 109) so the scope bundle can bind params. Nothing else reads them before their current position, so this is a pure move.
In the categorical block (:211-221), add the exclude branch after the existing include push:
for (const id of CATEGORICAL_WORD_PROPERTIES) {
const raw = (body.filters as Record<string, unknown> | undefined)?.[id]
?? (body as Record<string, unknown>)[id];
if (raw !== undefined && raw !== null) {
const values = (Array.isArray(raw) ? raw : [raw]).filter(
(v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 64,
);
if (values.length) {
wordsWhere.push(`w.${id} IN (${values.map(() => '?').join(', ')})`);
wordsParams.push(...values);
}
}
// Exclude mode (`${id}_exclude`, PHON-218): NULL-safe complement.
const rawEx = (body.filters as Record<string, unknown> | undefined)?.[`${id}_exclude`]
?? (body as Record<string, unknown>)[`${id}_exclude`];
if (rawEx === undefined || rawEx === null) continue;
const exValues = (Array.isArray(rawEx) ? rawEx : [rawEx]).filter(
(v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 64,
);
if (!exValues.length) continue;
wordsWhere.push(`(w.${id} IS NULL OR w.${id} NOT IN (${exValues.map(() => '?').join(', ')}))`);
wordsParams.push(...exValues);
}
Also update the default_register option's JSDoc in CompileOptions (line 60-64) to say "exclude the DEFAULT_SCOPE_EXCLUSIONS bundle" instead of naming only term_of_art/nomenclature.
- [ ] Step 5: Run the register tests, then the whole worker suite
Run: cd packages/web/workers && npx vitest run src/__tests__/registerFilter.test.ts && npm test
Expected: registerFilter PASSES. If other suites pin the old one-clause default (grep for NOT IN ('term_of_art' across src/__tests__/ — wordOrderSql.test.ts and api.test.ts are the likely candidates), update those pins to the new NULL-safe bound form; behavior change is the point of this task, silent test deletion is not.
- [ ] Step 6: Commit
git add packages/web/workers/src/config/properties.ts packages/web/workers/src/lib/wordFilter.ts packages/web/workers/src/__tests__/
git commit -m "feat(phon-218): DEFAULT_SCOPE_EXCLUSIONS full-bundle default scope + categorical exclude"
Task 3: /api/words/search accepts constraints[]; contrastive→pattern derivation moves Worker-side¶
Files:
- Modify: packages/web/workers/src/lib/constraintsToBody.ts (add options param)
- Modify: packages/web/workers/src/routes/words.ts:272 (search handler body parse)
- Test: packages/web/workers/src/__tests__/constraintsToBody.test.ts (extend)
Interfaces:
- Consumes: Task 1's constraintsToBody + Constraint union.
- Produces: constraintsToBody(constraints, base, opts?: ConstraintsToBodyOptions) where ConstraintsToBodyOptions = { derive_patterns_from_contrastive?: boolean } (default false). /api/words/search translates body.constraints before compiling. sentences.ts:618 is untouched (default false = its current behavior). Task 4 and Task 6 rely on this exact route behavior.
- [ ] Step 1: Write the failing tests
Append to constraintsToBody.test.ts:
describe('constraintsToBody: contrastive → pattern derivation (opt-in)', () => {
const minpair: Constraint[] = [
{ type: 'contrastive_minpair', phoneme1: 'p', phoneme2: 'b', position: 'initial' },
];
it('derives CONTAINS include patterns when opted in and no pattern exists', () => {
const out = constraintsToBody(minpair, base, { derive_patterns_from_contrastive: true });
expect(out.patterns).toEqual([
{ type: 'CONTAINS', phoneme: 'p', mode: 'include' },
{ type: 'CONTAINS', phoneme: 'b', mode: 'include' },
]);
});
it('multopp derives a single substitute pattern', () => {
const out = constraintsToBody(
[{ type: 'contrastive_multopp', substitute: 's', targets: ['t', 'k'] }] as Constraint[],
base,
{ derive_patterns_from_contrastive: true },
);
expect(out.patterns).toEqual([{ type: 'CONTAINS', phoneme: 's', mode: 'include' }]);
});
it('does NOT derive when any pattern (either mode) already exists — legacy Builder semantics', () => {
const out = constraintsToBody(
[
...minpair,
{ type: 'pattern', pattern_type: 'CONTAINS', phonemes: ['s'], mode: 'exclude' },
] as Constraint[],
base,
{ derive_patterns_from_contrastive: true },
);
expect(out.patterns).toEqual([{ type: 'CONTAINS', phoneme: 's', mode: 'exclude' }]);
});
it('does NOT derive by default (sentences semantics: contrastives are pair witnesses)', () => {
const out = constraintsToBody(minpair, base);
expect(out.patterns).toBeUndefined();
});
});
- [ ] Step 2: Run to verify failure
Run: cd packages/web/workers && npx vitest run src/__tests__/constraintsToBody.test.ts
Expected: FAIL — third argument not accepted / no derivation.
- [ ] Step 3: Implement the option
In constraintsToBody.ts, add above the function:
export interface ConstraintsToBodyOptions {
/**
* Synthesize CONTAINS include patterns from contrastive constraints when no
* explicit pattern (either mode) exists — a contrastive-only rule group
* still needs a populated words surface. /api/words/search opts in;
* /api/sentences must NOT (contrastive constraints there are pair
* witnesses, not per-word filters). Moved from frontend buildRequests.ts
* (PHON-218) so both endpoints share one translation.
*/
derive_patterns_from_contrastive?: boolean;
}
Change the signature to (constraints, base, opts: ConstraintsToBodyOptions = {}) and insert after the for...switch loop, before the final merge:
if (opts.derive_patterns_from_contrastive && patterns.length === 0) {
for (const c of constraints) {
if (c.type === 'contrastive_minpair' || c.type === 'contrastive_maxopp') {
patterns.push({ type: 'CONTAINS', phoneme: c.phoneme1, mode: 'include' });
patterns.push({ type: 'CONTAINS', phoneme: c.phoneme2, mode: 'include' });
} else if (c.type === 'contrastive_multopp') {
patterns.push({ type: 'CONTAINS', phoneme: c.substitute, mode: 'include' });
}
}
}
- [ ] Step 4: Wire
/api/words/search
In routes/words.ts, line 272 currently reads:
const body = await c.req.json<WordSearchBody>();
Replace with:
const rawBody = await c.req.json<WordSearchBody>();
// PHON-218: the frontend now sends Constraint[]; legacy flattened bodies
// still work (constraintsToBody is a no-op when constraints is absent).
const body = constraintsToBody(rawBody.constraints, rawBody, {
derive_patterns_from_contrastive: true,
});
Add the import next to the existing compileWordFilter import (line 20):
import { constraintsToBody } from '../lib/constraintsToBody';
Everything downstream reads body — no other change in the handler. Verify body.constraints itself flowing into compileWordFilter is harmless: compileWordFilter only reads known keys plus min_*/max_* roots and allowlisted categorical ids, so a leftover constraints key is inert.
- [ ] Step 5: Route-level test
Append to constraintsToBody.test.ts a route test ONLY if the D1-backed test harness supports /api/words/search (check __tests__/api.test.ts for an existing search POST — it exists as part of the seeded-fixture suite). Add to api.test.ts:
describe('POST /api/words/search with constraints[] (PHON-218)', () => {
it('constraints body compiles and returns the same shape as legacy', async () => {
const legacy = await SELF.fetch('http://localhost/api/words/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patterns: [{ type: 'STARTS_WITH', phoneme: 's', mode: 'include' }], limit: 5 }),
});
const viaConstraints = await SELF.fetch('http://localhost/api/words/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
constraints: [{ type: 'pattern', pattern_type: 'STARTS_WITH', phonemes: ['s'], mode: 'include' }],
limit: 5,
}),
});
expect(viaConstraints.status).toBe(200);
const a = await legacy.json();
const b = await viaConstraints.json();
expect(b.total).toEqual(a.total);
});
});
(If the api.test.ts fixture DB has no seeded words, mirror whatever seeding pattern its existing search tests use; if there are none, keep the test at status-and-shape level: expect(viaConstraints.status).toBe(200) and both total fields deep-equal.)
- [ ] Step 6: Run the worker suite
Run: cd packages/web/workers && npx tsc --noEmit && npm test
Expected: PASS.
- [ ] Step 7: Commit
git add packages/web/workers/src/lib/constraintsToBody.ts packages/web/workers/src/routes/words.ts packages/web/workers/src/__tests__/
git commit -m "feat(phon-218): /api/words/search accepts constraints[]; contrastive pattern derivation moves worker-side"
Task 4: SQL-level parity suite (legacy body vs constraints path)¶
Files:
- Test: packages/web/workers/src/__tests__/constraintCompilation.parity.test.ts (create)
Interfaces:
- Consumes: constraintsToBody (Tasks 1+3), compileWordFilter (Task 2).
- Produces: the regression suite the spec names — proof that for every representative constraint mix, the legacy flattened body and the constraints[] body compile to identical wordsWhere/propsWhere/pctWhere clauses and params. Guards PRs 3–6 (PHON-220–223): any change to either path that breaks equivalence fails here.
- [ ] Step 1: Write the suite (it should pass immediately — it pins, not drives)
/**
* PHON-218 parity oracle: a legacy flattened WordSearchBody and its
* Constraint[] equivalent MUST compile to identical SQL + binds. This is the
* migration guard for PHON-220..223 — while any caller still sends the legacy
* shape, both paths stay live and must never diverge.
*/
import { describe, it, expect } from 'vitest';
import { compileWordFilter } from '../lib/wordFilter';
import { constraintsToBody } from '../lib/constraintsToBody';
import type { Constraint, WordSearchBody } from '../types';
function expectSqlParity(legacy: WordSearchBody, constraints: Constraint[], base: WordSearchBody = {}) {
const a = compileWordFilter(legacy);
const b = compileWordFilter(
constraintsToBody(constraints, base, { derive_patterns_from_contrastive: true }),
);
expect(b.wordsWhere).toEqual(a.wordsWhere);
expect(b.propsWhere).toEqual(a.propsWhere);
expect(b.pctWhere).toEqual(a.pctWhere);
expect(b.params).toEqual(a.params);
expect(b.needsMedialPostFilter).toEqual(a.needsMedialPostFilter);
expect(b.medialSequences).toEqual(a.medialSequences);
}
describe('constraint compilation parity (legacy body vs constraints[])', () => {
it('empty', () => expectSqlParity({}, []));
it('patterns incl. multi-phoneme + medial + exclusion', () => {
expectSqlParity(
{
patterns: [
{ type: 'STARTS_WITH', phoneme: 's', mode: 'include' },
{ type: 'CONTAINS', phoneme: 'æ b', mode: 'include' },
{ type: 'CONTAINS_MEDIAL', phoneme: 'ɹ', mode: 'include' },
{ type: 'CONTAINS', phoneme: 'θ', mode: 'exclude' },
],
},
[
{ type: 'pattern', pattern_type: 'STARTS_WITH', phonemes: ['s'], mode: 'include' },
{ type: 'pattern', pattern_type: 'CONTAINS', phonemes: ['æ', 'b'], mode: 'include' },
{ type: 'pattern', pattern_type: 'CONTAINS_MEDIAL', phonemes: ['ɹ'], mode: 'include' },
{ type: 'pattern', pattern_type: 'CONTAINS', phonemes: ['θ'], mode: 'exclude' },
],
);
});
it('bounds: two-sided, percentile-suffixed, absolute', () => {
expectSqlParity(
{ filters: { min_aoa_percentile: 10, max_aoa_percentile: 60, min_phoneme_count: 3, max_phoneme_count: 6 } },
[
{ type: 'bound', norm: 'aoa_percentile', min_value: 10, max_value: 60 },
{ type: 'bound', norm: 'phoneme_count', min_value: 3, max_value: 6 },
],
);
});
it('cv_shape', () => {
expectSqlParity({ cv_shape: ['CVC', 'CCVC'] }, [{ type: 'cv_shape', shapes: ['CVC', 'CCVC'] }]);
});
it('flags + scope: has_image, lemmas_only, include_specialized', () => {
expectSqlParity(
{ has_image: true, lemmas_only: true, include_specialized: true },
[
{ type: 'flag', property: 'has_image', value: true },
{ type: 'scope', kind: 'lemmas_only' },
{ type: 'scope', kind: 'include_specialized' },
],
);
});
it('categorical include + exclude', () => {
expectSqlParity(
{ specialization: ['term_of_art'], currency_exclude: ['archaic'] },
[
{ type: 'categorical', property: 'specialization', values: ['term_of_art'] },
{ type: 'categorical', property: 'currency', values: ['archaic'], mode: 'exclude' },
],
);
});
it('contrastive-only group derives the same patterns the frontend used to', () => {
expectSqlParity(
{
patterns: [
{ type: 'CONTAINS', phoneme: 'p', mode: 'include' },
{ type: 'CONTAINS', phoneme: 'b', mode: 'include' },
],
},
[{ type: 'contrastive_minpair', phoneme1: 'p', phoneme2: 'b', position: 'initial' }],
);
});
it('kitchen sink', () => {
expectSqlParity(
{
patterns: [
{ type: 'STARTS_WITH', phoneme: 'k', mode: 'include' },
{ type: 'CONTAINS', phoneme: 'ð', mode: 'exclude' },
],
filters: { min_aoa_percentile: 10, max_aoa_percentile: 60 },
cv_shape: ['CVC'],
has_image: true,
lemmas_only: true,
specialization: ['everyday', 'slang'],
},
[
{ type: 'pattern', pattern_type: 'STARTS_WITH', phonemes: ['k'], mode: 'include' },
{ type: 'pattern', pattern_type: 'CONTAINS', phonemes: ['ð'], mode: 'exclude' },
{ type: 'bound', norm: 'aoa_percentile', min_value: 10, max_value: 60 },
{ type: 'cv_shape', shapes: ['CVC'] },
{ type: 'flag', property: 'has_image', value: true },
{ type: 'scope', kind: 'lemmas_only' },
{ type: 'categorical', property: 'specialization', values: ['everyday', 'slang'] },
],
);
});
});
- [ ] Step 2: Run it
Run: cd packages/web/workers && npx vitest run src/__tests__/constraintCompilation.parity.test.ts
Expected: PASS. Any failure here is a Task 1–3 bug — fix the implementation, do not adjust the oracle (the legacy side IS the oracle).
- [ ] Step 3: Commit
git add packages/web/workers/src/__tests__/constraintCompilation.parity.test.ts
git commit -m "test(phon-218): SQL-level parity suite — legacy body vs constraints path"
Task 5: Serve the scope definition via /api/property-metadata¶
Files:
- Modify: packages/web/workers/src/routes/meta.ts:47-59
- Modify: packages/web/workers/src/__tests__/api.test.ts:28-40, 230-240 (response-shape pins)
- Modify: packages/web/frontend/src/services/apiClient.ts:237-240 (+ types near PropertyCategory)
- Modify: packages/web/frontend/src/hooks/usePropertyMetadata.tsx
Interfaces:
- Consumes: DEFAULT_SCOPE_EXCLUSIONS, ScopeExclusion (Task 2).
- Produces: /api/property-metadata responds { categories: PropertyCategory[], default_scope: ScopeExclusion[] } (both surfaces). Frontend usePropertyMetadata() gains defaultScope: ScopeExclusion[] in its context state — PR 2 (PHON-219) builds the ScopeBar tooltip from it. All existing consumers keep working because the hook still exposes categories etc. unchanged.
- [ ] Step 1: Update the worker route
meta.ts — change the import (line 7) and the handler (lines 47-59):
import {
getSurfacedCategories,
getPlatformCategories,
DEFAULT_SCOPE_EXCLUSIONS,
} from '../config/properties';
meta.get('/property-metadata', (c) => {
// Default: full surfaced set (researcher-grade). surface=platform: curated
// SLP UI subset via platform_visible: true.
//
// PHON-218: the response also carries the default vocabulary scope so the
// UI enumerates DEFAULT_SCOPE_EXCLUSIONS from the definition instead of
// restating it in copy.
const surface = c.req.query('surface');
const categories = surface === 'platform' ? getPlatformCategories() : getSurfacedCategories();
return c.json({ categories, default_scope: DEFAULT_SCOPE_EXCLUSIONS });
});
- [ ] Step 2: Fix the worker route tests
In api.test.ts, both property-metadata describe blocks parse the response as a bare array. Update each to destructure — the pattern (apply to both, keeping each block's existing per-category assertions against body.categories where they previously used the root array):
const body = await response.json() as { categories: unknown[]; default_scope: unknown[] };
expect(Array.isArray(body.categories)).toBe(true);
expect(body.default_scope).toEqual(
expect.arrayContaining([
expect.objectContaining({ property: 'specialization' }),
expect.objectContaining({ property: 'currency' }),
expect.objectContaining({ property: 'nativization' }),
]),
);
Run: cd packages/web/workers && npx vitest run src/__tests__/api.test.ts — Expected: PASS after the edits.
- [ ] Step 3: Update the frontend client + hook
apiClient.ts — add next to PropertyCategory and change the method:
export interface ScopeExclusion {
property: string;
values: string[];
}
export interface PropertyMetadataResponse {
categories: PropertyCategory[];
default_scope: ScopeExclusion[];
}
async getPropertyMetadata(opts?: { surface?: 'platform' }): Promise<PropertyMetadataResponse> {
const qs = opts?.surface ? `?surface=${opts.surface}` : '';
return this.get(`/api/property-metadata${qs}`);
}
usePropertyMetadata.tsx — add defaultScope to the state:
import type { PropertyCategory, PropertyDef, ScopeExclusion } from '../services/apiClient';
In PropertyMetadataState, add /** DEFAULT_SCOPE_EXCLUSIONS, served by the worker (PHON-218) */ defaultScope: ScopeExclusion[];; in defaultState, add defaultScope: [],. In load():
const [metadata, ranges] = await Promise.all([
api.getPropertyMetadata({ surface: 'platform' }),
api.getPropertyRanges(),
]);
if (cancelled) return;
const { categories, default_scope } = metadata;
…and include defaultScope: default_scope, in the setState object (the rest of the function already iterates categories and needs no change beyond the destructure).
- [ ] Step 4: Sweep for other
getPropertyMetadatacallers
Run: grep -rn "getPropertyMetadata" packages/web/frontend/src — the hook should be the only runtime caller; update any test mocks that resolve a bare array to resolve { categories: [...], default_scope: [] } instead.
- [ ] Step 5: Run both suites
Run: cd packages/web/workers && npx tsc --noEmit && npm test
Run: cd packages/web/frontend && npx tsc --noEmit && npm test
Expected: PASS both.
- [ ] Step 6: Commit
git add packages/web/workers/src/routes/meta.ts packages/web/workers/src/__tests__/api.test.ts packages/web/frontend/src/services/apiClient.ts packages/web/frontend/src/hooks/usePropertyMetadata.tsx
git commit -m "feat(phon-218): serve DEFAULT_SCOPE_EXCLUSIONS through /api/property-metadata"
(Include any test-mock files from Step 4 in the git add.)
Task 6: Frontend — new Constraint variants; buildWordSearchRequest becomes pure serialization¶
Files:
- Modify: packages/web/frontend/src/types/governance.ts (union + StoreEntry)
- Modify: packages/web/frontend/src/services/apiClient.ts (WordSearchRequest gains constraints)
- Modify: packages/web/frontend/src/lib/rules/buildRequests.ts
- Modify: packages/web/frontend/src/lib/rules/buildRequests.test.ts
- Replace: packages/web/frontend/src/lib/rules/buildRequests.parity.test.ts
Interfaces:
- Consumes: worker behavior from Tasks 1–3 (the wire contract: constraints[] + similar_to/sort_by/sort_order/limit).
- Produces: buildWordSearchRequest(constraints, opts, limit) — SAME signature, unchanged WordOptions, but the return value is now { constraints: [...serialized...], similar_to, sort_by, sort_order, limit }; WordOptions booleans are appended to the constraints array as flag/scope constraints. Frontend Constraint union gains categorical/flag/scope (mirroring worker types.ts — spec Section A). StoreEntry gains matching variants for PR 2's composers. Callers (Builder.tsx, useSampleWords.ts, packSeeder.ts) require zero changes — verify, don't edit.
- [ ] Step 1: Extend
types/governance.ts
After CvShapeConstraint (line 63), add (mirror of the worker's Task-1 definitions — keep JSDoc in sync):
export interface CategoricalConstraint {
type: "categorical";
property: string;
values: string[];
mode?: ConstraintMode;
}
export interface FlagConstraint {
type: "flag";
property: string;
value: boolean;
}
export interface ScopeConstraint {
type: "scope";
kind: "lemmas_only" | "include_specialized";
}
Extend the Constraint union with | CategoricalConstraint | FlagConstraint | ScopeConstraint, and add to StoreEntry:
| { type: "categorical"; property: string; values: string[]; mode: ConstraintMode }
| { type: "flag"; property: string; value: boolean }
| { type: "scope"; kind: "lemmas_only" | "include_specialized" }
In lib/constraintCompiler.ts, append three pass-through loops before return result; (same 1:1 idiom as the existing blocks):
for (const e of entries) {
if (e.type !== 'categorical') continue;
result.push({ type: 'categorical', property: e.property, values: e.values, mode: e.mode });
}
for (const e of entries) {
if (e.type !== 'flag') continue;
result.push({ type: 'flag', property: e.property, value: e.value });
}
for (const e of entries) {
if (e.type !== 'scope') continue;
result.push({ type: 'scope', kind: e.kind });
}
- [ ] Step 2: Extend
WordSearchRequestinapiClient.ts
Add to the interface (line 80), importing Constraint from ../types/governance:
/** PHON-218: preferred payload — the Worker compiles these. The flattened
* fields below (patterns/filters/cv_shape/has_image/lemmas_only/
* include_specialized) are the legacy shape, kept until PHON-220..223
* finish migrating; do not add new fields to the legacy shape. */
constraints?: Constraint[];
- [ ] Step 3: Rewrite
buildWordSearchRequest
Replace the function body in buildRequests.ts (signature and WordOptions unchanged; buildSentenceRequest and buildContrastQuery unchanged):
export function buildWordSearchRequest(
constraints: Constraint[],
opts: WordOptions = {},
limit = 200,
): WordSearchRequest {
// PHON-218: pure serialization — the Worker owns compilation (including the
// contrastive→pattern derivation that used to live here). WordOptions
// booleans ride as flag/scope constraints so callers keep their signature.
const all: Constraint[] = [...constraints];
if (opts.hasImage) all.push({ type: 'flag', property: 'has_image', value: true });
if (opts.includeSpecialized) all.push({ type: 'scope', kind: 'include_specialized' });
if (opts.lemmasOnly) all.push({ type: 'scope', kind: 'lemmas_only' });
return {
constraints: all.length ? all : undefined,
similar_to: opts.similarTo,
sort_by: opts.sortBy,
sort_order: opts.sortOrder,
limit,
};
}
- [ ] Step 4: Replace the parity test; update the unit test
Delete buildRequests.parity.test.ts's legacy-oracle machinery — its job (pinning the wire shape against pre-refactor Builder) is superseded: wire-shape equivalence is now proven at the SQL level by the worker suite from Task 4 (constraintCompilation.parity.test.ts), which this file's replacement must name in a header comment. Write the replacement in the same file path:
/**
* buildWordSearchRequest serialization contract (PHON-218).
*
* The old parity oracle (legacy Builder.handleBuild wire shape) is retired:
* the request shape changed deliberately — the frontend now ships
* Constraint[] and the Worker compiles it. Semantic equivalence between the
* legacy flattened shape and this one is pinned Worker-side in
* packages/web/workers/src/__tests__/constraintCompilation.parity.test.ts.
* This file pins the serialization: constraints pass through untouched,
* WordOptions booleans append as flag/scope constraints, execution options
* ride at the top level.
*/
import { describe, it, expect } from 'vitest';
import { buildWordSearchRequest } from './buildRequests';
import type { Constraint } from '../../types/governance';
describe('buildWordSearchRequest serialization', () => {
it('passes constraints through untouched and appends nothing when opts are empty', () => {
const constraints: Constraint[] = [
{ type: 'pattern', pattern_type: 'STARTS_WITH', phonemes: ['s'], mode: 'include' },
{ type: 'bound', norm: 'aoa_percentile', min_value: 10, max_value: 60 },
{ type: 'cv_shape', shapes: ['CVC'] },
];
const req = buildWordSearchRequest(constraints, {}, 200);
expect(req.constraints).toEqual(constraints);
expect(req.limit).toBe(200);
expect(req.sort_by).toBeUndefined();
});
it('WordOptions booleans append as flag/scope constraints in fixed order', () => {
const req = buildWordSearchRequest([], { hasImage: true, includeSpecialized: true, lemmasOnly: true });
expect(req.constraints).toEqual([
{ type: 'flag', property: 'has_image', value: true },
{ type: 'scope', kind: 'include_specialized' },
{ type: 'scope', kind: 'lemmas_only' },
]);
});
it('false/absent options append nothing; empty everything → constraints undefined', () => {
const req = buildWordSearchRequest([], { hasImage: false });
expect(req.constraints).toBeUndefined();
});
it('execution options ride at the top level', () => {
const similarTo = {
word: 'cat',
weights: { onset: 0.5, nucleus: 0.3, coda: 0.2 },
threshold: 0.9,
position: 'initial' as const,
syllable_count: 1,
};
const req = buildWordSearchRequest([], { similarTo, sortBy: 'frequency', sortOrder: 'desc' }, 50);
expect(req.similar_to).toEqual(similarTo);
expect(req.sort_by).toBe('frequency');
expect(req.sort_order).toBe('desc');
expect(req.limit).toBe(50);
});
});
Update buildRequests.test.ts the same way: any assertion on req.patterns/req.filters/req.has_image etc. becomes an assertion on req.constraints content. Keep every input case; only the expected shape changes. buildContrastQuery and buildSentenceRequest tests are untouched.
- [ ] Step 5: Verify callers need no edits
Run: grep -rn "buildWordSearchRequest" packages/web/frontend/src --include="*.ts" --include="*.tsx" | grep -v test
Expected callers: components/Builder.tsx, hooks/useSampleWords.ts, lib/seed/packSeeder.ts. None destructure the result's legacy fields — verify with: grep -n "\.patterns\|\.filters\|\.has_image\|\.lemmas_only" packages/web/frontend/src/hooks/useSampleWords.ts packages/web/frontend/src/lib/seed/packSeeder.ts on the request objects specifically. If any caller inspects the built request's legacy fields, fix that caller to inspect its own inputs instead (the request object is opaque transport). Builder.test.tsx mocks the API client, not the request shape — but run it to confirm.
- [ ] Step 6: Run the frontend suite + build
Run: cd packages/web/frontend && npx tsc --noEmit && npm run lint && npm test && npm run build
Expected: PASS. (Build throws without VITE_API_URL only in CI-style production builds of the Pages config — the local npm run build uses the dev fallback only under command === 'serve'; if the build demands VITE_API_URL, set VITE_API_URL=http://localhost:8787 for the command.)
- [ ] Step 7: End-to-end smoke on local dev (staging-shaped verification)
Start the worker (cd packages/web/workers && npm run dev) and frontend (cd packages/web/frontend && npm run dev), open Word Lists, and Build with: one STARTS_WITH pattern + one property slider moved + Base forms ON + Has image ON. Verify results return and the network tab shows a constraints-shaped POST body. This is the zero-visual-change check — the UI must look and behave exactly as before.
- [ ] Step 8: Commit
git add packages/web/frontend/src/types/governance.ts packages/web/frontend/src/lib/constraintCompiler.ts packages/web/frontend/src/services/apiClient.ts packages/web/frontend/src/lib/rules/
git commit -m "feat(phon-218): frontend emits Constraint[] — buildWordSearchRequest is pure serialization"
Task 7: Full matrix, push, PR¶
Files: none new.
- [ ] Step 1: Full test matrix (the exact CI set)
cd packages/web/workers && npx tsc --noEmit && npm test
cd ../frontend && npx tsc --noEmit && npm run lint && npm test && npm run build
cd ../../.. && uv run python -m pytest packages/data/tests/ --ignore=packages/data/tests/test_datasets.py --ignore=packages/data/tests/test_new_loaders.py
Expected: all green. (Python is untouched by this PR but CI runs it; a pre-existing failure is a stop-and-report, not a skip.)
- [ ] Step 2: Verify branch state and push
git branch --show-current # must print feat/phon-217-constraint-ui-unification
git log --oneline origin/develop..HEAD
git push
(Concurrent-session discipline: confirm the branch before pushing — another thread can move HEAD.)
- [ ] Step 3: Open the PR to develop
gh pr create --base develop \
--title "feat(phon-218): unified constraint model + worker-owned compiler + named default scope" \
--body "$(cat <<'EOF'
PR 1 of 6 for PHON-217 (constraint model + UI unification; spec in
docs/superpowers/specs/2026-08-14-constraint-ui-unification-design.md).
Implements PHON-218. Zero visual change.
- Constraint union gains categorical / flag / scope variants (frontend + worker mirror)
- Worker owns all compilation: /api/words/search accepts constraints[]; the
contrastive→pattern derivation moved worker-side (opt-in, sentences unaffected);
buildWordSearchRequest is pure serialization
- DEFAULT_SCOPE_EXCLUSIONS: the default vocabulary universe is one named,
bound, NULL-safe definition (specialization term_of_art/nomenclature +
currency historicism/archaic/obsolete + nativization unassimilated), served
via /api/property-metadata — behavior change: 193 archaic/historicism +
13 unassimilated words leave default word lists (restorable via the toggle)
- Categorical exclude mode (`<property>_exclude`) compiles NULL-safe NOT IN
- New suites: constraintsToBody unit tests + SQL-level parity
(constraintCompilation.parity.test.ts) guarding the PHON-220..223 migration
No reseed. Blocks prod promotion together with PHON-219..223.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
- [ ] Step 4: Watch CI + staging deploy, then verify on staging
After merge (user approves per cadence), confirm the Deploy Staging run is green, then on staging Word Lists: default Build no longer returns betwixt/musket-class words; "Include technical vocabulary" ON restores them; a normal pattern search behaves identically to before.
Self-Review Notes¶
- Spec coverage: Section A model → Tasks 1+6; single compiler + words/search constraints → Task 3; DEFAULT_SCOPE_EXCLUSIONS + metadata serving → Tasks 2+5; parity suite → Task 4; "buildRequests reduced to serialization" → Task 6; zero visual change → Task 6 Step 7. Sections B–D are PHON-219..223, out of scope here by design.
- Known behavior change (intentional, spec-approved): the default scope expands beyond PR #248's gate; Task 7 Step 4 verifies it on staging.
- Type consistency:
ScopeExclusiondefined worker-side (Task 2) and frontend-side (Task 5) with matching shape;Constraintunion mirrored by hand (Tasks 1 and 6) — the parity suite catches semantic drift, and both files carry keep-in-sync comments. sort_by/sort_order: never become constraints (execution options per spec); the weighted-sample default is untouched until PHON-221.