Superseded by ADR 0007.
This document proposed a keyword-scoped location model. Canonry’s accepted and implemented model keeps locations project-scoped and uses them as run context. The historical proposal remains below for reference only.
Add location as a first-class dimension to keyword tracking. Each keyword can be tracked from specific geographic locations (down to city level), and provider APIs receive location hints so search results reflect what users in that location would see.
country/language per project but never passes them to provider APIs{ country, region?, city?, timezone? } — maps directly to OpenAI/Claude user_location API params_default instead of nullable locationId — avoids SQLite NULL uniqueness issuesquery_snapshots at query time. Survives renames, deletes, and project country changes.| Provider | Param | Fields |
|---|---|---|
OpenAI web_search_preview |
user_location |
{ type: 'approximate', country?, region?, city?, timezone? } |
Claude web_search_20250305 |
user_location |
{ type: 'approximate', country?, region?, city?, timezone? } |
Gemini googleSearch |
None in SDK | Prompt-level hint |
| Local | N/A | Prompt-level hint |
locations tableFile: packages/db/src/schema.ts
export const locations = sqliteTable('locations', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
label: text('label').notNull(), // "_default" for project default, or auto-generated "Springfield, Illinois, US"
country: text('country'), // ISO 3166-1 alpha-2
region: text('region'), // Free text
city: text('city'), // Free text
timezone: text('timezone'), // IANA timezone
createdAt: text('created_at').notNull(),
}, (table) => [
index('idx_locations_project').on(table.projectId),
uniqueIndex('idx_locations_project_label').on(table.projectId, table.label),
])
locationId to keywords tablelocationId: text('location_id').notNull().default('_default').references(() => locations.id, { onDelete: 'restrict' })(projectId, keyword) → (projectId, keyword, locationId)_default location row during project creationlocationContext to querySnapshots tablelocationContext: text('location_context').notNull().default('{}'){ locationId, label, country, region, city, timezone }File: packages/db/src/migrate.ts
CREATE TABLE IF NOT EXISTS locations (...)_default location per existing project (inherits project country)ALTER TABLE keywords ADD COLUMN location_id TEXT NOT NULL DEFAULT '_default'UPDATE keywords SET location_id = (SELECT id FROM locations WHERE project_id = keywords.project_id AND label = '_default')(project_id, keyword, location_id) indexALTER TABLE query_snapshots ADD COLUMN location_context TEXT NOT NULL DEFAULT '{}'File: packages/contracts/src/location.ts (new)
LocationDto: { id, label, country?, region?, city?, timezone? }CreateLocationInput: { country?, region?, city?, timezone?, label? }SnapshotLocationContext: { locationId, label, country?, region?, city?, timezone? }TrackedQueryInput (packages/contracts/src/provider.ts): add country?, region?, city?, timezone?packages/contracts/src/run.ts): add locationContext?: SnapshotLocationContextpackages/contracts/src/config-schema.ts): spec.locations array, keyword { keyword, locations } objectsFiles: packages/provider-*/src/adapter.ts
Each adapter’s executeTrackedQuery (line ~49) manually reconstructs input — must forward new country/region/city/timezone fields. Also update internal *TrackedQueryInput types in each normalize.ts.
user_locationFile: packages/provider-openai/src/normalize.ts
Pass user_location: { type: 'approximate', country, region, city, timezone } to web_search_preview tool config.
user_locationFile: packages/provider-claude/src/normalize.ts
Same structure on web_search_20250305 tool config (includes timezone).
File: packages/provider-gemini/src/normalize.ts
Modify buildPrompt() to append (searching from Springfield, Illinois, US) when location provided.
File: packages/provider-local/src/normalize.ts
Same prompt-level approach.
File: packages/canonry/src/job-runner.ts
country/region/city/timezone from location record to TrackedQueryInputlocationContext JSON at insert time (line 149)File: packages/api-routes/src/locations.ts (new)
GET /projects/:name/locations — list (includes _default)POST /projects/:name/locations — create. Auto-generate label from fields.DELETE /projects/:name/locations/:id — 409 if keywords reference it, cannot delete _defaultFile: packages/api-routes/src/index.ts — register locationRoutes
File: packages/api-routes/src/keywords.ts
locationId or locationLabel per keyword. Default to _default.location: LocationDtoFile: packages/api-routes/src/projects.ts (line 138)
Include spec.locations array and per-keyword location references. Plain string for _default, object for located keywords.
File: packages/api-routes/src/history.ts
Add keywordId and locationLabel to timeline response entries. Same keyword with different locations = separate timeline entries (already naturally separated by keywordId).
File: packages/api-routes/src/runs.ts
Include parsed locationContext in snapshot responses.
File: packages/canonry/src/commands/location.ts (new)
canonry location add <project> --country US --region Illinois --city Springfieldcanonry location list <project>canonry location remove <project> <label>File: packages/canonry/src/commands/keyword.ts
canonry keyword add <project> "best dentist" --location "Springfield, Illinois, US"--location, uses _defaultFiles: packages/canonry/src/client.ts, packages/canonry/src/cli.ts
File: apps/web/src/api.ts
ApiKeyword: add locationId, location?: LocationDtoApiSnapshot: add locationContextApiTimelineEntry: add keywordId, locationLabelfetchLocations()File: apps/web/src/view-models.ts
CitationInsightVm: add locationId, locationLabelProjectCommandCenterVm: add locationScores[]File: apps/web/src/build-dashboard.ts
buildProjectCommandCenter(): accept locationFilter? param, filter snapshots before aggregationbuildEvidenceFromTimeline() (line 183): re-key seenKeywords set and snapshot grouping on keywordId not keyword textbuildInsights() (line 326): group phraseMap on keyword + '::' + locationIdlocationScores from snapshot locationContextFile: apps/web/src/App.tsx
_default)All + location labels, clicking re-computes entire VM for that locationFile: packages/api-routes/test/export-roundtrip.test.ts
spec.locations and keyword location refsVerify each adapter forwards location fields to API call / prompt.
_default sentinel location row per project adds a small overhead but avoids NULL uniqueness edge cases in SQLite.ON DELETE RESTRICT on location FK means users must reassign keywords before deleting a location.| File | Phase | Changes |
|---|---|---|
packages/db/src/schema.ts |
1 | locations table, locationId on keywords, locationContext on snapshots |
packages/db/src/migrate.ts |
1 | Migration: create table, backfill, swap index |
packages/contracts/src/location.ts |
1 | New — LocationDto, CreateLocationInput, SnapshotLocationContext |
packages/contracts/src/index.ts |
1 | Export location types |
packages/contracts/src/provider.ts |
1 | Extend TrackedQueryInput |
packages/contracts/src/run.ts |
1 | Extend snapshot DTO |
packages/contracts/src/config-schema.ts |
1 | locations in spec, keyword location references |
packages/provider-openai/src/adapter.ts |
2 | Forward location fields |
packages/provider-openai/src/normalize.ts |
2 | Pass user_location to web_search_preview |
packages/provider-claude/src/adapter.ts |
2 | Forward location fields |
packages/provider-claude/src/normalize.ts |
2 | Pass user_location to web_search_20250305 |
packages/provider-gemini/src/adapter.ts |
2 | Forward location fields |
packages/provider-gemini/src/normalize.ts |
2 | Location hint in buildPrompt() |
packages/provider-local/src/adapter.ts |
2 | Forward location fields |
packages/provider-local/src/normalize.ts |
2 | Location hint in buildPrompt() |
packages/canonry/src/job-runner.ts |
2 | Fetch locations, pass to providers, snapshot locationContext |
packages/api-routes/src/index.ts |
3 | Register locationRoutes |
packages/api-routes/src/locations.ts |
3 | New — location CRUD |
packages/api-routes/src/keywords.ts |
3 | Handle locationId in keyword CRUD |
packages/api-routes/src/projects.ts |
3 | Locations in export |
packages/api-routes/src/runs.ts |
3 | locationContext in snapshot response |
packages/api-routes/src/history.ts |
3 | keywordId + locationLabel in timeline |
packages/canonry/src/client.ts |
4 | Location client methods |
packages/canonry/src/cli.ts |
4 | Register location command |
packages/canonry/src/commands/location.ts |
4 | New — location CLI |
packages/canonry/src/commands/keyword.ts |
4 | –location flag |
apps/web/src/api.ts |
5 | Extend types, fetchLocations |
apps/web/src/view-models.ts |
5 | locationId/label, locationScores |
apps/web/src/build-dashboard.ts |
5 | Filter param, re-key on keywordId, locationScores |
apps/web/src/App.tsx |
5 | LocationBadge, breakdown card, filter chips |
packages/api-routes/test/export-roundtrip.test.ts |
6 | Location round-trip test |