# School’s Out developer API

Start an external integration with the canonical organization catalog. The OpenAPI
reference at `/openapi.json` owns exact operation parameters, response shapes and
access requirements. It also includes explicitly marked `x-internal` product
operations; their presence grants neither access nor an external stability promise.
Legacy Insights datasets require ADMIN access, not an ordinary paid plan.

## Basics

- Base URL: `https://apis.schoolsoutapp.com`.
- Use a named integration key from an eligible account in `x-api-key`. Account
  credentials, browser sessions and internal service credentials are different authorities.
- Keep keys server-side. Never put them in URLs, browser bundles, source control,
  logs, email or chat. Create one named key per integration; deploy its replacement
  before revoking the old one. `/api-keys` on Intelligence redirects to key management
  on the auth site.
- An explicit `x-api-key` takes precedence; an invalid header never falls back to a cookie.
  Intelligence's HTTP-only cookie uses `credentials: include`; cookie mutations also
  require a trusted origin and a fresh session-bound token from `/api/auth/csrf`.

```sh
BASE="https://apis.schoolsoutapp.com"
KEY="$SCHOOLS_OUT_API_KEY"

curl --get "$BASE/api/organizations" \
  -H "x-api-key: $KEY" \
  --data-urlencode "region=WA" \
  --data-urlencode "limit=25"
```

## Supported endpoints

This is an orientation, not a second endpoint inventory. Consult `/openapi.json`
for each published operation and the route directory at `/` for product discovery.

| Read | Boundary |
| --- | --- |
| `/api/health` | Public process/connectivity status; not an end-to-end readiness proof |
| `/api/organizations` and documented canonical detail/facet/score reads | Current catalog/field access; named keys require their owner's eligible plan |
| `/api/insights` and its dataset/search routes | Legacy ADMIN-only compatibility reads |
| Operations marked `x-internal` | Product/operator contracts with their own account, role, origin and CSRF rules |

SQL, generic database access and writes are outside the external developer contract.
Never request a service credential to bypass an account or plan gate. Staff metadata
at `/api/canonical/reference` describes field/storage meaning, not record values or
listing verification. Its `generatedAt` is the metadata read time; keep any
`coverageIssues` visible and distinguish database nullability from editor requirements.

## Plans and data boundaries

The API resolves current permissions and allowances on every request. Plan descriptions
explain access; they do not authorize an operation.

| Plan | API tier | Distinction |
| --- | --- | --- |
| Free | `free` | Lifetime Research allowance; ordinary catalog reads require Starter |
| Starter | `paid` | Canonical catalog reads; premium and business-contact fields stay masked |
| Pro | `pro` | Pricing/capacity fields and named integration keys |
| Team | `enterprise` | Business identity/contact fields and pooled access |
| Enterprise | `licensed` | Licensed seats and custom allotments |

A named key follows its owner's live effective plan, team pool and revocation.
Stored coordinates keep their precision wherever disclosed; Team does not grant
extra decimal places. Numeric precision does not establish venue accuracy. Private
family data and ingestion records are not external integration datasets. Legacy
Insights access remains ADMIN-only regardless of these paid plans.

## Organization catalog and scores

Organization, activity, and session JSON lists and details omit bookkeeping,
ingestion history, null and blank record fields, and unknown availability,
registration-status, and coordinate-precision values. Zero, false, empty arrays,
masked fields, open-ended range positions, and structured schedules keep their meanings.
Age ranges use `age_range_years`; normal JSON records and column metadata omit the
redundant `age_range_months` alias. CSV, SQL, and the legacy `ageMonths` filter
retain month compatibility. Load bookkeeping separately through
`GET /api/organizations/{id}/provenance`, `/api/activities/{id}/provenance`, or
`/api/sessions/{id}/provenance`. These retain detail gates and field masks; all
previews are denied. ADMIN user-backed readers, including their machine keys, also
receive field provenance and raw ingestion, merge, and relationship history.

The internal staff/ADMIN timeline at
`GET /api/organizations/{id}/provenance/timeline?limit=30&cursor=...` returns
`data: {organizationId, entries, nextCursor, coverage}`. It summarizes settled camp
and associated child contributions, not scraper completions or a complete audit.
Use the opaque organization-bound cursor unchanged, including after an empty page
with a non-null cursor. `limit` accepts 1–100.

Only ADMIN reconstructs before/after values and historical organization scores from
retained accepted contributions in a read-only snapshot; staff receive restricted
sides. A value side is `available` (with `value`, including explicit null),
`unavailable`, or `restricted`. Missing/ambiguous history must stay unavailable.
Media sides contain sanitized display items, not raw sources or personal attribution.

Historical scores use the current rubric at the recorded time. Current child
associations, missing history and explicitly identified current-value fallbacks
limit their coverage; later recorded evidence is excluded. A missing score is not
zero, and clients must not substitute today's score. Read the OpenAPI timeline
schemas for exact coverage/fallback fields. Preview, machine, service and delegated
credentials cannot read this timeline. Normal raw provenance keeps its separate
ADMIN-only boundary.

`GET /api/organizations`, `/api/activities`, and `/api/sessions` include each
entity's own `image_urls` on ordinary JSON rows, ordered by selected cover, logo
where applicable, then gallery position and deduplicated by resource. Empty galleries
return `[]`. Remote URLs are exact; uploaded URLs retain protected brand-asset access.
Other entities' media is excluded. Previews and CSV omit this field.

Main detail responses also include `data.media`, the first gallery page of up to
100 images, with the same shape as `/api/{organizations,activities,sessions}/{id}/media`.
It includes display URLs, labels, kinds, dimensions, selections, and pagination;
source attribution stays out even for ADMIN readers. Fetch subsequent pages or
permitted source attribution through `/media` only when needed. Use
`includeProvenance=false` for gallery-only pages and `includeProvenance=true&limit=1`
for one image’s sources at its offset. On `/media`, omitted/true retains existing
authority-gated source access; false skips it. This is a strict single boolean. All previews omit
media. Optional `includeMedia=false` skips the gallery for follow-up diagnostic reads;
it accepts one exact true or false value, defaulting to true.

Normal organization, activity, and session JSON records and column metadata omit
`score`, and list/detail reads do not load or calculate scores. Request scores
separately when needed:

- `GET /api/{organizations,activities,sessions}/{id}/score` returns live score detail.
- `GET /api/{organizations,activities,sessions}/scores?ids=UUID,UUID` returns live
  summaries for up to 24 distinct IDs. Add `cached=true` to read saved summaries
  without loading evidence or calculating scores. The flag accepts one exact `true`
  or `false`; omitted or `false` retains live behavior.

Batch responses contain `scores`, `missing`, `scoringVersion`, and `tier`. Cached
results follow requested ID order; missing, deleted, blacklisted, and unprepared
records stay in `missing`, with no live fallback. Organization caches calculated
with an older scoring version also stay missing until the updated worker refreshes
them. Cached batches retain Starter+
access and deny previews. Organization detail's deprecated `includeScore=false`
is accepted as a no-op; `includeScore=true` returns 400 with the separate endpoint.
Intelligence fetches cached summaries only while its Records table shows scores.

Organization scoring version `v2` combines accuracy, freshness, completeness, and
activity using their relative weights 20:12:13:20. Programs/funding, safety, and
operator responsiveness are removed, including their input parameters, caps, and
flags. Remaining Activity indicator weights are normalized by their sum.
Confidence uses collected session and integrity signals, source depth, dated
fields, and merge confidence; it determines the displayed interval without
pulling the overall toward 50. Activity and session scoring formulas remain at
`v1`. Legacy SQL `programs` and `safety` columns are null after a v2 cache refresh.

Rankings, cached summaries, `activities.score`, and `organization_scores` share
persisted results refreshed by workers after source changes and roughly hourly for
decay and calendar rules. Pausing workers retains previous results;
`organization_scores.calculated_at` identifies their evaluation time. API availability
depends on the installed score schema, independently of worker availability.
`minimumScore` and `maximumScore` are inclusive, and score filters and ordering run
in PostgreSQL before pagination, so `total` is the exact matching count of prepared
scores. CSV and SQL retain their existing projections.

For example, scores are whole numbers, so this counts organizations strictly above
80 while requesting only one matching row:

```sh
curl --get "$BASE/api/organizations" \
  -H "x-api-key: $KEY" \
  --data-urlencode "minimumScore=81" \
  --data-urlencode "limit=1"
```

Use `orderBy=score` to return the matching organizations in score order:

```sh
curl --get "$BASE/api/organizations" \
  -H "x-api-key: $KEY" \
  --data-urlencode "region=WA" \
  --data-urlencode "minimumScore=80" \
  --data-urlencode "orderBy=score" \
  --data-urlencode "order=desc" \
  --data-urlencode "limit=25"
```

The other supported filters are repeatable `q` (up to five terms), `city`, `region`,
`category`, whole-year `ageYears`, legacy `ageMonths`, and `grade`. JSON pages default to 24 rows and accept at
most 100. Set `includeRows=false` on canonical organization, activity, or session
lists to return counts and metadata with an empty entity array. It cannot be combined
with `format=csv`. Organization fields are masked to the key owner’s plan.

Organizations, activities, and sessions separate `canonical_url`, the verified official
primary source, from `relevant_urls`, an array of supporting or unverified pages.
Supporting URLs can include third-party directories and do not establish an official
site. Both fields require Team data access. An omitted `canonical_url` at a permitted
tier means an official primary source has not been confirmed; it does not mean the entity lacks a website.
The former `registration_url` field is retired; retained registration links appear
in `relevant_urls`. The legacy organization URL columns are no longer current fields;
archived values do not repopulate `relevant_urls` after a removal.

Activity and session edit forms also expose these URL fields. Supporting-link edits
add to existing links; an explicit clear removes all links on that record. Proposals
must use the current values returned by `edit_form` as their expected baseline and
still require captured evidence and human approval. Activities and sessions expose
independent nullable `overnight` status: true means explicitly overnight, false
means explicitly day, and unknown is omitted from normal catalog JSON. Do not
derive it from a parent's status or unrelated programs.

`entity_configurations` owns categories, amenities, time zone, ages, and flags for
each organization, activity, and session separately. Do not derive organization
categories from activities or inherit categories into sessions. New age bounds use
whole completed years in inclusive `age_range_years`; preserve `age_text` and open
endpoints. At legacy boundaries, decode month ranges to inclusive bounds before
converting with `floor(months/12)`: the historical 71-month upper bound converts to
5 years because years were encoded as `years*12+11`. `amenities` is optional prose
separate from accessibility. Configuration flag bit 1 is the staff-managed
`early_bird` flag. Never infer it from historical discount prose.

Sessions own `registration_open_date`, `registration_close_date`, and ordered `prices`
entries with `price`, `basis`, and `currency` keys, an optional `description`, and
optional `before_care_pricing` and `after_care_pricing` quotes. A description is source-grounded
text that distinguishes an option, such as `Members` or `Non-members`, and may also
describe a single option. It accepts text or null; surrounding whitespace is trimmed
and blank descriptions are omitted. No default label is inferred, and a description
alone does not make an option valid. Each tuition value can be null
when unknown; an entry with all three values null must include a care quote.
Care-only entries leave tuition unknown; zero care amounts mean free care only. `prices: []` means no recorded session
price, and zero means explicitly free. Entries keep source order and duplicates; the
API does not infer currency or basis. `session` basis covers the whole enrollment.
Their attendance dates and care clocks belong to `schedule_configurations`; public
scalar date/time fields are read-only summaries.
Session `discounts` holds nullable freeform source terms. It replaces
`discount_available`, `early_bird_discount`, and `sibling_discount`; it does not
change prices or set the separate early-bird configuration flag. Session discount
text requires the same Pro access as enrollment prices.
Each price option owns its optional care quotes, which need an amount and currency
plus a billing basis only when stated. Session-level `before_care_pricing` and
`after_care_pricing` are read-only summaries present only when every option records
the same quote; edit care pricing within `prices`. Schedule configurations retain
their own independent care quotes. Organization care prices
are retired. Activities have no enrollment price, currency, discount, or
care-pricing fields; sessions own enrollment pricing and discounts. Activity `image_url` is a
read-only compatibility alias for its selected active cover. Retired care-availability
prose is not converted into schedule availability booleans.

Canonical chart counts use `GET /api/{organizations|activities|sessions}/facets`
with a single `field` and optional `limit`. Add `snapshot=true` to use incrementally maintained
counts for unfiltered requests. Search (`search` for records or `q` for
facet values) and list filters use live counts even with that flag. Omit it for live
facets. Snapshot requests use durable counts after relevant background work settles and
fall back to live counts while committed edits, deletion, blacklisting, or calendar
expiry work is pending. Failed refreshes leave that live fallback active. Authentication
and field access remain live. `snapshot` must be `true` or `false`; `true` requires `field`.

### Structured canonical filters

For plain-language input, send `POST /api/canonical/search/interpret` with
`{"kind":"organization","text":"Organizations within 10 km of 47.6062, -122.3321"}`.
The API uses GPT-6 Luna to return either
`{success:true,data:{status:"ready",kind,filter,feedback?}}` or
`{success:true,data:{status:"clarification",message}}`. A ready filter is already
validated against the current account's visible fields, operators, and related
record scopes. Apply it through the list, facet, or map routes below; interpretation
does not fetch records. Only submit on an explicit search action. Reuse the returned
filter for paging, view changes, and shared URLs. Understandable requests receive a
useful best-effort filter and usually no feedback. Optional `feedback` is reserved
for an important note about a material interpretation assumption or approximation
that changes expected results. Routine defaults, normal interpretation, recaps, and
generic caveats stay silent. Apply the ready filter without confirmation. Feedback
is one nonblank string of at most 2000 UTF-16 code units, omitted when there is no
important note. Omitted constraints
generally remain unrestricted; missing necessary values may use reasonable defaults.
Only unintelligible or clearly misguided intent requires clarification. An unrestricted
request may return `{"field":"id","op":"present"}`. Feedback never bypasses
validation: clarification, malformed or disallowed provider output, and errors must
preserve existing filters and results. Cookie callers
need the normal session-bound CSRF token. This route uses catalog access/read
allowances and does not create an Ask report or consume an Ask question.
In search text, `camp`/`camps` mean `organization`/`organizations`; for example,
"Camps in Oregon" matches organizations in Oregon without requiring "camp" in their
names. Explicit names such as "Camp Fire" stay intact. The selected `kind` stays
fixed; camp properties in activity/session searches use the organization relation.

Send one URL-encoded JSON `filter` to `GET /api/organizations`, `/api/activities`,
`/api/sessions`, or their `/facets` routes. It combines with the existing query
parameters using AND. On facets, `search` filters records and `q` searches facet
values; the same `filter` narrows the list and facet population before counting.
Filtered chart requests remain live even with `snapshot=true`.

Map, member, and regional-summary routes use the same language with the
[camp-cohort scope](#explorer-map) and optional `filterKind` described below.

Read `GET /api/canonical/schema` first. Each entity table's `filterCapabilities`
lists the visible `fields` with their types and `operators`, available `relations`
and `predicates`, and request limits. The metadata follows the current plan and
preview disclosure; a hidden field is rejected even inside an `any` branch.
Use canonical field names such as `age_range_years`, not legacy aliases.
The OpenAPI `CanonicalFilter` schema describes the recursive wire format.

Paid, non-preview readers can filter each record's persisted overall `score`
(numeric 0–100) and `score_band` (`weak`, `fair`, `good`, `strong`). These are
filter-only fields; records still omit scores. For example,
`{"field":"score","op":"gt","value":80}` or
`{"field":"score_band","op":"in","value":["good","strong"]}`.
Use `all` with `gte` and `lte` for an inclusive numeric range. Missing scores match
`missing` or `eq: null`, never zero or an `unavailable` band. Related predicates use
the related record's score; filtering never calculates or refreshes scores.

Every node has exactly one form:

| Form | Meaning |
|------|---------|
| `{all: [node, ...]}` | Every child predicate must match; the array is nonempty |
| `{any: [node, ...]}` | At least one child predicate must match; the array is nonempty |
| `{field, op, value?}` | Compare a field using an operator advertised for that field |
| `{related: {relation, match, where: node}}` | `match: "any"` requires a related match; `"none"` requires no related match |
| `{radius: {latitude, longitude, radiusMeters}}` | Match the selected entity's own address-grade point within a positive radius |
| `{price: {minimum?, maximum?, currency, basis}}` | Match a recorded session tuition quote, using at least one inclusive amount bound |
| `{schedule: {...}}` | Match session attendance dates, weekdays, daily hours, or their combination |

`missing` means database null only, and `eq` with `value: null` has the same meaning.
`present` includes false, zero, blank text, and empty collections. `empty` matches
empty or space-only text, an empty array, or an empty JSON object and excludes null. These three
operators omit `value`. Unknown does not mean false, free, eligible, or unrestricted.
Text `eq` and `in` compare exact values; `containsText` is a literal case-insensitive
substring, including literal `%` and `_`. Numeric/date/time fields advertise
`lt`, `lte`, `gt`, and `gte`. Dates use `YYYY-MM-DD`, timestamps use RFC 3339, and
numbers and booleans stay JSON numbers and booleans.

For region searches, use `{"field":"region","op":"matchesRegion","value":"Oregon"}`.
This region-only operator accepts nonblank text and treats US state/territory codes
and full names as aliases (`OR` and `Oregon`), ignoring case and surrounding ASCII
whitespace. Unrecognized labels use trimmed case-insensitive equality. The legacy
`region=` parameter uses the same matching. Neither establishes country or geographic
membership; stored labels remain unchanged. Use `eq`/`in` for literal spellings and
an `any` group of `matchesRegion` predicates for multiple regions.

Array `containsAny` and `containsAll` compare whole members; non-array JSON does not
match. Categories and text arrays require string members. JSON `eq` compares the
whole structural value. Whole-year ages and grades use `contains` with one integer,
or `overlaps` with `[inclusiveMinimum, inclusiveMaximum]`; a null endpoint is open.
An unknown range still does not match. `in` takes a nonempty array of non-null
scalar values. Use capability metadata for the supported combination of field,
operator, and value type. There is no general negation.

Organizations relate to `activities` and `sessions`; activities relate to
`organization` and `sessions`; sessions relate to `organization` and `activity`.
Put every condition that must hold on one session inside a single `related.where`
`all` group. Separate related predicates can match different sessions. A session's
direct organization owns it when present; its activity supplies the organization
only when the direct owner is absent. Organization descendant searches preserve
that owner through nested activity/session conditions. Categories, audience facts,
and location stay on their own entity and do not inherit from a parent.

This example finds activities with a single session whose recorded age range includes
ten and which records a tuition quote of at most USD 400 per week:

```sh
curl --get "$BASE/api/activities" \
  -H "x-api-key: $KEY" \
  --data-urlencode 'filter={"related":{"relation":"sessions","match":"any","where":{"all":[{"field":"age_range_years","op":"contains","value":10},{"price":{"maximum":400,"currency":"USD","basis":"week"}}]}}}'
```

Price predicates are session-only, including inside related session predicates.
`minimum` and `maximum` are finite nonnegative amounts and must be ordered.
Both bounds, the uppercase three-letter `currency`, and the recorded `basis`
(`hour`, `day`, `week`, `month`, or `session`) apply to one tuition quote. The API
does not convert currency or rate bases, multiply attendance, add fees/care, or
claim a comparable total. Missing currency, basis, or amount cannot satisfy the
predicate; a zero amount can. Separate price nodes may match different quotes.

Radius predicates require the selected entity's own valid coordinates and
`coordinate_precision` of `exact`, `building`, `parcel`, or `street`. Missing,
masked, locality/state/ZIP, and unknown-precision points do not match. This strict
predicate does not use a parent location or the broader possible-area
classifications of the [location query API](#canonical-location-queries).

Schedules are session-only and require `mode: "overlap"` or `"covers"`. Date
limits are optional: select unique `dates: ["2027-06-14", ...]`, inclusive
`from`/`to` dates, or neither. `daysOfWeek: ["monday", ..., "sunday"]` is allowed
with `from`/`to` or without date limits, but not with explicit `dates`. Omitting
weekdays from a range selects all dates. Queries with date limits select at most
366 dates within a 366-day horizon. Explicit attendance and weekly rules honor
exclusions; a broad source date range does not establish attendance on a particular
date. At least one date, weekday, time point, or time window is required.

Omit clocks for attendance-only matching. For a time point, supply `atTime` in
`HH:mm` or `HH:mm:ss`; opening is inclusive and closing exclusive. For a window,
supply both `startTime` and a later `endTime` in the same clock formats; only the
end may be `24:00` or `24:00:00`. `atTime` cannot combine with either window clock.
Timed matches require recorded `daily` hours. Adjacent windows do not overlap.
With date limits, `overlap` matches any requested attendance date and `covers`
matches every requested date. A point must fall within known hours; a window must
overlap in `overlap` mode or be fully covered by one source window in `covers` mode.

Without date limits, either mode matches any qualifying configuration. Known daily
hours can satisfy a time-only request even when attendance dates are unknown.
Weekday requests match any requested weekday with an actual, non-excluded
occurrence in the same configuration as any requested hours. For example,
`{"schedule":{"mode":"overlap","atTime":"17:00"}}` finds sessions whose known
daily hours include 5 p.m.; add `"daysOfWeek":["monday"]` to require an actual
Monday occurrence. Optional `includeCare: true` extends daily hours for a point or
window only with explicitly available care and known care clocks; it requires a
timed request and Starter access to care hours. Missing care is never inferred.

`timeZone` defaults to `venueLocal`: compare each venue's recorded civil dates and
clocks directly, even when its time zone is unknown. An explicit IANA zone such as
`America/Los_Angeles` interprets the request in that zone and compares instants;
timed source matches need the session's own known IANA zone. Nonexistent or
ambiguous request endpoints are rejected, and core source endpoints in clock gaps or
folds do not match. IANA mode requires explicit dates or a `from`/`to` range plus a
time point or window. If an optional care extension
has an invalid endpoint, valid core hours remain usable; care cannot rescue an
invalid core window. Parent time zones are not substituted.

```json
{
  "schedule": {
    "mode": "covers",
    "from": "2027-06-14",
    "to": "2027-06-18",
    "daysOfWeek": ["monday", "tuesday", "wednesday", "thursday", "friday"],
    "startTime": "09:00",
    "endTime": "17:00",
    "timeZone": "venueLocal",
    "includeCare": true
  }
}
```

Malformed, repeated, unknown, over-complex, and incorrectly typed filters return
400; unavailable fields return 403. Never retry by dropping a predicate. An empty
result means no disclosed catalog record matched the checked filter, not that no
provider exists or that registration availability was verified.

## Response envelopes

Insights success responses use:

```json
{
  "data": { "...": "..." },
  "success": true
}
```

Their failures use:

```json
{
  "data": null,
  "error": true,
  "message": "Description."
}
```

A missing, invalid, or rotated account key returns HTTP 401:

```json
{ "authorized": false }
```

## Insights

These compatibility reads require ADMIN access, including for a named key's owner.
Use the dataset slug returned by `/api/insights`, never a guessed physical table name.
The catalog's `data.capabilities` is the visible union; each dataset narrows it to
its own available filters/search/sort fields. `dynamicFilter` exists only for a
visible `data` object. OpenAPI owns exact parameter bounds and examples.

```sh
curl "$BASE/api/insights" -H "x-api-key: $KEY"
curl --get "$BASE/api/insights/atlanta_camps" \
  -H "x-api-key: $KEY" \
  --data-urlencode "q=art camp" \
  --data-urlencode "state=GA" \
  --data-urlencode "orderBy=name" \
  --data-urlencode "limit=50"
```

Dataset reads use `limit`/`offset`; JSON and CSV have different bounds. `format=csv`
returns one filtered page, not a complete export. Use returned pagination metadata.
Named exact filters are case-sensitive and must be supported by the dataset;
`data.<key>` compares one top-level JSON value as text. Dataset `q` searches names
and retains PostgreSQL `%`/`_` wildcard behavior.

`/api/insights/search` ranks exact, prefix and substring matches across the visible
search fields. `%`, `_` and `!` are literal there. A request accepts up to five
nonblank `q` values, each at most 200 UTF-16 code units; other parameters occur once.
An optional `dataset` and supported exact filters narrow it; `data.<key>` is not
accepted. A cross-dataset search skips datasets lacking a requested filter, while an
explicit unsupported dataset/filter is a 400. Copy `nextOffset` into the next request;
do not infer complete coverage from one page. Malformed, unknown, blank or unsupported
parameters fail instead of being silently dropped.

## Errors and retry behavior

Inspect the route's response body and Access requirements before deciding to retry.

| Status | Handling |
| --- | --- |
| 400 | Correct the input; never repair a search by silently dropping a condition |
| 401 | Supply a current valid credential; stop retrying a rejected one |
| 402 | Inspect plan, Research-credit or export-allowance metadata |
| 403 | Respect the role, field, plan, origin or CSRF boundary; status alone is not an upgrade instruction |
| 404 | Check the resource identifier, current access and feature availability |
| 429 | Honor `Retry-After` and returned allowance/capacity metadata; avoid retry loops |
| 500/503 | Retain the failure and follow operation-specific guidance; reconcile an uncertain mutation before resubmitting |

Legacy ranked search has a bounded database deadline and capacity response; narrow
a timed-out search rather than repeating it unchanged. Other product routes can use
503 for different unavailable dependencies. Read responses and headers such as
`x-reads-remaining` and `x-export-remaining`; do not copy tunable allowances from a guide.

## Internal product reference: location queries and staff backfills

These routes serve School’s Out products. They are marked `x-internal` in OpenAPI and
are outside the external integration contract above. Named machine keys cannot execute
canonical SQL. An account credential or the signed Intelligence cookie resolves the current
account; cookie POST requests also require the session-bound CSRF token and trusted
origin. Never request a service credential to bypass an account or plan gate.

### Explorer map

`GET /api/canonical/map`, `/api/canonical/map/stream`,
`/api/canonical/map/members`, and `/api/canonical/map/summary` accept one URL-encoded
JSON `filter`. Optional `filterKind` is `organization` (default), `activity`, or
`session`, and requires `filter`. Use the selected root's `filterCapabilities`
from `/api/canonical/schema`; the [same typed filter language](#structured-canonical-filters)
and limits apply. The original tree keeps its full depth, node and byte budget;
selecting a child root does not consume an extra relationship level.

A matching organization selects that camp. A matching activity or session selects
its owning camp; a session's direct organization wins over activity fallback.
The map population then includes the selected camps and all their disclosed,
effectively owned activities and sessions, including children that did not match
the tree. Existing query parameters combine with this population using AND, and
region/viewport membership still uses each record's own location. Mixed metadata,
member pages and regional summaries count that resulting population. Camp dots use
the camp's own address-grade coordinates, never the matching child's venue.
For example, a radius on a session can select a camp whose headquarters is outside
that radius; use a related organization radius when the camp itself must be near.

```sh
curl --get "$BASE/api/canonical/map" \
  -H "x-api-key: $KEY" \
  --data-urlencode 'bbox=-125,24,-66,50' \
  --data-urlencode 'zoom=3' \
  --data-urlencode 'includeCampPoints=true' \
  --data-urlencode 'filterKind=session' \
  --data-urlencode 'filter={"all":[{"field":"age_range_years","op":"contains","value":10},{"price":{"maximum":400,"currency":"USD","basis":"week"}}]}'
```

Preserve `filter` and `filterKind` across point snapshots, viewport metadata,
member pages and regional summaries. JSON, CBOR, finite stream v1/v2, and live SSE
use the same camp selection. Filter changes define a new response/cursor scope.
Preview matches only its fixed disclosed samples and owner relationships, so filters
cannot expose more rows; regional summaries remain unavailable in preview.
Malformed/repeated filters or unsupported root kinds return 400, and hidden fields
or relationships return 403. Never retry by discarding a condition.

Explorer's filter menu uses current API capabilities for fields, operators,
relationships, groups, radius, quotes and attendance. Every applied condition and
nested group remains visible. Edits take effect when applied; malformed or
unsupported copied filters remain visible as errors. Global name/place lookup is
separate from these applied filters:

`GET /api/canonical/search?mode=suggestions&q=...&limit=10` finds records across
the permitted catalog independently of the map viewport and filters. Exact names
rank before prefixes, then other name/place matches, with stable name/kind/UUID
ties; `%`, `_`, and `!` are literal. `limit` is bounded to 1–20. The response has
`results` without an exact `total`; each row contains canonical kind and ID, name,
parent identity, city/region, linked camp verification, and nullable `organization_id`.
Optional `location` is the linked active nonblacklisted camp's saved coordinate pair
and source precision. Accessible coordinates retain full stored precision on every
plan within finite map bounds ±85/±180.
It never uses a child venue or viewport centroid. Preview matches only its fixed
disclosed samples and masks, including relationship resolution. Omitting `mode`
retains the ordinary account search contract. Preview ordinary search returns 403
and requires `mode=suggestions`.

Map, member, boundary, and regional-summary successes support opt-in
`Accept: application/vnd.schoolsout.map+cbor;version=1`. The response uses that
versioned Content-Type and the same `{success: true, data: ...}` model as JSON,
including nulls, omitted fields, GeoJSON geometry objects, and RFC3339 timestamp
strings. Coordinates retain float64 precision. The binary representation is chosen
only when explicitly accepted at positive quality at least as high as JSON;
JSON matching honors specific media ranges before wildcards. Missing Accept,
wildcards alone, unsupported versions, refusals, and malformed values fall back to
JSON. Error responses remain JSON with existing status and fields, including
402/403 access refusals and 503 `map_cache_warming`. Authentication, preview
sampling/disclosure, and the preview prohibition on regional summaries are unchanged.
Successes vary on Accept; all four routes reuse negotiated gzip and vary on
Accept-Encoding. Existing private/no-store cache policy remains in force.

The map route also accepts
`application/vnd.schoolsout.map-stream+cbor;version=1` for progressive camp points.
After decompression, read a four-byte unsigned big-endian length followed by that
many CBOR bytes, with a maximum 1 MiB payload. Each frame has `type`: `start`
contains `version: 1`, requested `bounds`, and an opaque `revision`; `points` contains
zero-based `sequence` and `features`; `result.data` is the ordinary map metadata
without `campPoints`; `complete` contains matching `pointCount` and `batchCount`.
There may be zero point batches. Gzip covers the whole stream and flushes each frame.

For the complete camp-point population without map metadata, explicitly set
`includeCampPoints=true` and accept
`application/vnd.schoolsout.map-stream+cbor;version=2`; omitted or false returns 400.
It uses the same framing, gzip, 1 MiB frame limit, error handling, and completion rules.
The `start` frame contains `version: 2`, `bounds`, `revision`, and ordered `columns`:
`id`, `longitude`, `latitude`, then optional `name` and `scoreBand` according
to the corresponding include flags. Each `points` frame contains zero-based `sequence`
and positional `rows` matching those columns; coordinates remain float64 and a missing
or undisclosed included name is null. `complete` contains matching `pointCount` and
`batchCount`. V2 has no `features` or `result` frame and does not calculate grouping,
coverage, viewport, tier, region, or preview metadata. Selected-region validation still
returns pre-stream HTTP 400 for a missing boundary/dataset or non-active dataset; it
never substitutes an empty successful point population. Accept quality chooses v1 or v2.

Points are provisional until `complete`. An `error` frame carries `code` and
`message` after streaming begins; refusals before streaming retain JSON status/errors.
Abort, truncation, malformed frames, invalid sequence/counts, and overflow must not
be accepted as complete coverage. Use a bounded JSON/ordinary-CBOR fallback when the
stream representation cannot be consumed. Only completed streams may be cached.

`X-Map-Revision` and `start.revision` are opaque equality tokens, scoped to source,
disclosure, policy, and UTC day; do not parse them. Successful responses also carry a
weak `ETag` bound to the normalized query, representation, compression, and disclosure.
Send it in `If-None-Match` to revalidate. The API checks current authorization and a
fresh source fence before returning 304 with no body and without running the map data
query. A wildcard does not prove that a response completed. Retain a streamed ETag only
after its `complete` frame passes all sequence and count checks. An empty stream revision
means it is unavailable. These validators do not force an already loaded point snapshot
to refresh.

Explorer loads the permitted world-bounds camp-point cohort for the current
filters/access, then follows the live SSE route described below. Pan/zoom requests
camera-specific metadata and groups independently. Completed same-scope dots remain
usable while a replacement loads; a failed or partial generation never becomes a
complete snapshot. Names can arrive up front even when their labels are hidden.
Neither progressive delivery nor caching guarantees a fixed request latency.

Map responses can reuse bounded per-process finished representations only after
current authorization and database validity checks. Source, policy, geography and
applicable score changes invalidate reuse; failures are not cached. Restarts start
cold. This does not change private/no-store headers. Regional-summary `asOf` is the
original evaluation time, not a new verification time. Workers own durable projection
refresh; API reads may overlay committed pending source changes.

`GET /api/canonical/map?bbox=west,south,east,north&zoom=...` returns a bounded
GeoJSON collection containing organizations, activities, and sessions together.
The same catalog filters select the population before spatial grouping. Each record
uses its own configuration; child venues never inherit headquarters coordinates.
Coordinates are masked before viewport membership, grouping, and output.

Each feature contains `properties.featureType` (`point` or `cluster`), `groupId`,
`count`, and `counts` keyed by `organization`, `activity`, and `session`. Single
records also identify their `targetKind` and `recordId`. Counts are records, not
distinct camps. World-anchored grid cells coarsen to the feature budget without
sampling. `viewport.scope=intersecting_grid_cells` includes complete cells touching
the requested bounds; it can include nearby records just beyond the screen edge.
`coverage` describes the complete matching permitted population and separates
address-grade, approximate, and unlocated records. Preview reads use a fixed
policy-disclosed sample before filters and geography are applied.

Add `includeCampPoints=true` to include an optional `campPoints` GeoJSON collection
for a camp density layer. It contains only organizations with `exact`, `building`,
`parcel`, or `street` precision inside the requested bounds, including their edges.
The same projected coordinates, filters, selected region, and preview disclosure
apply. Features contain the organization UUID as `id`, a Point geometry, and empty
`properties` by default; coincident camps retain separate IDs. ZIP/state representative
points and child records are excluded. Overflow returns HTTP 413 rather than partial points;
zoom in or add filters. Omitted or false preserves the existing response.

Set `includeCampNames=true` to add disclosed `properties.name` to these same points.
It requires `includeCampPoints=true` (otherwise HTTP 400), reuses viewer-level text
scrubbing and preview name masks, and omits hidden/missing names without a fallback.
Omitted/false adds no name property and skips camp-point name reads. Clients can request names up front for their completed point cohort; label visibility is a client zoom/collision decision.

Set `includeCampScores=true` to add `properties.scoreBand` from the persisted
organization score. It requires `includeCampPoints=true` (otherwise HTTP 400).
Bands are `weak`, `fair`, `good`, `strong`, or `unavailable`, using the same
thresholds as organization score details. Missing prepared scores, restricted
accounts, and every preview receive `unavailable`. Omitted or false skips
score-band reads. Workers calculate and refresh scores; map reads do not
calculate them. V2 point streams include an optional `scoreBand` column after
`name` when names are included, or immediately after the coordinates otherwise.

Staff may manage shared camp collections through `/api/collections`. GET lists active
collections by default; `archived=true` lists archived collections and `archived=all`
lists both. POST creates a collection with `{name}`. GET/PATCH `/api/collections/{id}`
read or rename/archive a collection. PUT/DELETE
`/api/collections/{id}/organizations/{organizationId}` add or remove a camp. Names
are unique without regard to case while active. Archived collections remain readable
and preserve membership; they cannot be renamed or changed and cannot be restored.
Collection responses distinguish all retained memberships (`memberCount`) from
currently available camps (`availableMemberCount`). These routes require a direct
staff or ADMIN account; a machine key, service delegation, or preview cannot manage
collections. Set `collectionId` on organization, map, member, or summary reads to
filter to a staff-curated collection. The filter is organization-only and staff-only.
Use `GET /api/collections/{id}/organizations?organizationIds=...` to check up to 100
camp UUIDs at once; it returns `{organizationIds, unavailableOrganizationIds}` from
one database snapshot, with no names. The first array contains requested available
members; the second contains requested IDs that are missing, deleted, or blacklisted,
without distinguishing why. Archived collections remain readable through this lookup.
Only absence from the first array for an available ID confirms non-membership; an
unavailable ID omitted there cannot confirm that removal succeeded. `createdBy` is null
if the creator's account is deleted; the shared collection and memberships remain. The legacy
`ageMonths` filter remains supported, and a whole-year range represented to legacy
month clients expands inclusively as `[yearMin*12, yearMax*12+11]`.

`GET /api/canonical/map/stream` accepts the same map query with
`includeCampPoints=true` and returns `text/event-stream`. Use it for live camp dots:
load one world-bounds snapshot, then apply upserts and removals without polling the
finite map route. Viewport metadata, groups, and boundaries remain separate reads.

- `start`: version 1, `mode` (`snapshot` or `delta`), opaque `revision`,
  `sourceRevision`, `bounds`, ordered `columns`, and `fromRevision` for a delta.
- `points`: consecutive zero-based `sequence`, compact tuple `rows` to upsert,
  and `removeIds` (empty in a snapshot). Columns follow the v2 point contract.
- `complete`: `revision`, final `pointCount`, `batchCount`, `upsertCount`, and
  `removeCount`. Only this event carries SSE `id:revision`.
- `ready`: `revision` and `sourceRevision` when the client's completed cursor is
  already current; it sends no replacement points.
- `error`: sanitized `code` and `message`, optionally `status`; the incomplete
  generation must be discarded.

Commit the generation and cursor only after validating all batches and completion.
Send the committed cursor as `Last-Event-ID` when reconnecting. Retained history
produces a delta; missing history produces a complete replacement. Keep completed
same-scope dots visible while replacements load. Initial batches may display
progressively; partial deltas never replace the completed map. Access refusals clear
protected data immediately. The cursor is scoped to the point query and disclosure;
`sourceRevision` is the separate metadata equality token. Do not order either token.

The API retains bounded snapshot history in process memory and checks database
revision fences before sharing a changed point build. It does not read the full point
population on every heartbeat. Bounded connections renew authorization on reconnect;
ordinary reconnects resume without downloading the snapshot again. Process restart,
replica changes, or history eviction may require a replacement. Deploy the API route
before the matching Intelligence client. There is no database migration.

`GET /api/canonical/map/members?group=...` returns a group's complete membership
through bounded pages. Send the same catalog/region filters, then pass `nextCursor`
as `after` until it is null. Records sharing coordinates remain selectable.

`GET /api/canonical/map/boundaries?kind=...&bbox=...&zoom=...` reads installed
state, metro, county, city, neighborhood, or ZIP outlines. Use `q` for source-name
search or `code` for one boundary. Features include source, vintage, bounds, and
immutable `datasetId`, `kind`, and `code`; availability and truncation are explicit.
City features distinguish incorporated places from Census-designated places.
Neighborhood coverage depends on authoritative local imports.
Use `point=longitude,latitude` to find source boundaries covering a map point,
including their edges, independently of viewport and display simplification. Send
one finite point within longitude ±180 and latitude ±85, without `bbox`, `code`, or
`q`. Optional `zoom` still controls returned display detail, and `datasetId` selects
the installed snapshot. Overlapping candidates use stable code order; feature and
byte budgets still apply.

To select a region, send `regionKind`, `regionCode`, and `regionDataset` with map
or member reads. The dataset is an immutable imported ID, including an installed
retired version. Map, member, and summary membership uses the distance from each
viewer-masked stored point to the unsimplified source boundary. A match occurs when
that distance is at most the selected layer's tolerance: initially 100 m for
neighborhoods, 500 m for cities/ZIPs, 2 km for counties, 5 km for metros, and 10 km
for states. A tolerance circle only needs to overlap the region; its center may
be outside, and one record may match multiple regions. Exact/address-grade and
ZIP/state approximate points follow the same rule; source-area polygons are not
used for membership. Tolerances do not depend on zoom. Missing, masked, invalid,
and unknown-precision locations are excluded. Approximate counts retain their
quality classification. This region rule does not change the structured location
classifications below. All three reads require current catalog access; preview
sessions also require `explorer.browse_masked`.

### Agent canonical discovery

Ask and Assistant expose `search_canonical` with `{kind, filter?, query?, limit?,
offset?}` alongside `run_canonical_sql`. Prefer the structured tool for discovery
and audience, price, radius, or schedule conditions; use SQL for analytical
aggregates and queries the filter tree cannot express. Both tools consume the same
query budget and retain deadlines, cancellation, field disclosure, and evidence
handling. Ask returns at most 30 root records per structured call; Assistant uses
its smaller detail-result limit. A response budget may shorten a page at complete
row boundaries: copy returned `nextOffset` exactly into `offset`, rather than
advancing by `limit`. `rowCount` and `hasMore` describe the delivered page. If no
complete row fits, the output is unavailable and truncated; use SQL to select fewer
fields instead of treating it as a catalog miss.

Search evidence scopes each row to the selected root kind. Parent summaries do not
fill missing child facts or count as additional results. An empty success is a
checked catalog search, and the pagination total counts matching roots rather than
real-world providers. Neither quote nor schedule matching confirms registration
availability or a comparable total price.

Ask `status.query` progress keeps its existing number, source, state, duration and
bounded result summary. A structured read adds optional `search` arguments and
sends `sql: ""`; SQL reads omit `search`. Assistant status events keep query
details private. This tool is separate from the existing global
`GET /api/canonical/search` text-search endpoint.

### Canonical location queries

`POST /api/canonical/query` accepts `{sql, params?, location?, timeoutSeconds?}`. Statements stop
after 15 seconds; only the service credential without `x-viewer-id` may set `timeoutSeconds`
(15–60) for unattended background reads. Direct reads retain
paid-plan gates; Free receives canonical results through internal Ask. Omitting
`location` preserves the existing query contract. Its two supported forms are:

```json
{"type":"point","latitude":47.6062,"longitude":-122.3321,"radiusMeters":5000}
```

```json
{"type":"area","kind":"zip","code":"98101","radiusMeters":1000}
```

Area kind is `state` or `zip`, with a two-letter US state code or five-digit ZIP.
The optional area radius defaults to zero and extends from the area boundary.
A point requires a positive radius in meters. Missing reference areas return an
explicit error; do not substitute the center. City filters retain recorded-name
matching. Ask reference points may come from an address geocoder, local reference data, or an
explicitly labeled model estimate; estimates support approximate ranking and do not
establish exact nearest results or exact radius cutoffs when uncertainty matters.

A location request adds `location_match` and `distance_meters` to the masked
organization, activity, and session views. It does not remove rows. For example:

```json
{
  "sql":"select location_match, count(*)::int from activities group by location_match",
  "location":{"type":"area","kind":"state","code":"WA"}
}
```

| Classification | Interpretation |
|---|---|
| `point_match` | Address-level point within the requested area/distance |
| `possible_area_match` | ZIP area intersects the requested area/distance; the venue location is approximate |
| `state_coverage` | Broad state-only coverage; not a nearby venue |
| `no_match` | Known point or area lies outside the request |
| `unresolved` | Missing coordinates or required boundary |

Count address points separately from possible area matches. `exact`, `building`,
`parcel`, and `street` precision can produce point distances; ZIP/state approximations
have null venue distances. Accessible coordinates and point calculations retain full
stored precision on every plan. `coordinate_precision` describes source quality, not
numeric precision or guaranteed geographic correctness. Endpoint gates and preview
field masks still apply. All Ask users may receive ZIP-area classifications; this does
not expose hidden ZIPs, polygons, boundary identifiers, or derivation metadata.
SQL cannot call arbitrary PostGIS functions or access physical geography tables.

To combine activity and session matches with `UNION ALL` and sort using a `CASE`
expression, put the union in a CTE or subquery and apply `ORDER BY` in an outer
`SELECT`. PostgreSQL permits only output column names or positions in an `ORDER BY`
directly attached to a union. That invalid query shape returns HTTP 400 with repair
guidance, allowing Ask to revise the query.

`data.location` reports the normalized filter, available active/retained reference
versions and sources, and `pointCoordinates=source` for current responses. Historical
saved metadata may retain `rounded_to_two_decimal_places`; preserve its recorded
precision limits when reading those reports. Preserve source attribution and Census
ZCTA vintage in the report. Reference metadata lists available versions, not the exact
sources used by every returned row. `/api/canonical/schema` describes the conditional columns.

### Family bookings

Parent-approved camp bookings. The routes exist only when the API runs with
`BOOKINGS_ENABLED=true`; otherwise each returns 404 and apps hide booking features.
Every route acts as the signed-in parent: the Intelligence session cookie plus CSRF on
mutations, or the parent's own account credential in `x-api-key`. Machine keys, service
delegation, staff access and billing teams never reach them.

| Method | Route | Purpose |
|---|---|---|
| GET | `/api/users/viewer/payment-method` | `data.paymentMethod` is the saved card as `{brand, last4}`, or `null` |
| POST | `/api/users/viewer/payment-method/setup` | `{returnUrl}` with an allowlisted origin; returns the Stripe-hosted setup URL |
| GET | `/api/users/viewer/bookings` | The parent's 100 most recent bookings, without event history |
| POST | `/api/users/viewer/bookings` | Approve a booking: 201 with the new booking, 200 for a replayed `idempotencyKey` |
| GET | `/api/users/viewer/bookings/{bookingId}` | One booking with its event history |
| POST | `/api/users/viewer/bookings/{bookingId}/decisions/{decisionId}` | `{outcome:"accept", optionId}` or `{outcome:"decline"}` |
| POST | `/api/users/viewer/bookings/{bookingId}/cancel` | Cancel before the camp's checkout starts payment |

Approval sends `{idempotencyKey, childProfileId, childProfileVersion, sessionId,
maxSpend:{amountCents, currency}, allowWaitlist, autoAcceptDecisions, shareFields,
answers, approveBooking:true}`. Generate `idempotencyKey` once per approval form and
resend the identical body after a network error or 5xx; a reused key with a different
body returns 409. `childProfileVersion` is the version the parent reviewed, and the
profile must allow reuse as it does for registration. `maxSpend` is the spending limit
including fees and tax, at most 2,500,000 cents in the session's currency; the hold on
the parent's card is for exactly that amount. `shareFields` may name only
`child.birthDate`, `child.grade`, `child.allergies` and `guardian.phone`; the child's
name and the guardian's name and email are always shared. A child has one active or
confirmed booking per session across all of its guardians; another approval returns 409.

Read a booking again after `pollAfterMs` and stop when it is `null`. Decide what to show
from `phase`, never from `status`; `status` is the finer step for support, and new values
may join an existing phase. Each phase fixes which payloads are present:

| `phase` | Meaning | Payload |
|---|---|---|
| `in_progress` | School's Out is placing the hold or booking with the camp | none |
| `needs_parent` | The camp asked a question only the parent can answer | `decision` |
| `booked` | The camp enrolled the child | `receipt` |
| `waitlisted` | The child holds a waitlist place, maybe with a deposit | `receipt` |
| `not_booked` | Ended without a place | `failure`, or `null` after a clean cancel |
| `checking` | The outcome is not known yet; never show it as booked or failed | `checkingWith` |

Payloads outside their phase are always `null`. `checkingWith` is `card` when a card hold
could not be resolved (nothing reached the camp, the parent may cancel, and the child may
be booked again) and `camp` while staff confirm the outcome with the camp. `paymentStatus`
is separate: a `booked` booking may still be `held` while its charge settles. Show an
amount only when `receipt.amount` is present.

While a booking is in `needs_parent`, `decision` carries the camp's question, its
options (each with a `total` when the choice changes the price), an optional
`proposedOptionId` and `expiresAt`. Declining ends the booking and releases the hold.
With `autoAcceptDecisions`, School's Out answers routine questions and accepts session
changes within the spending limit on its own, recorded with `decidedBy:"auto"`; it never
raises spending above the limit, signs a waiver or creates a camp account.

Failures use the standard envelope; show `message` to the parent as is. 400 is an
invalid approval or answer. 401 without a session or credential is `{authorized: false}`;
a machine key or delegated request gets the envelope. 404 is a booking that does not
exist, is not this parent's, or bookings turned off. 409 on approval is a changed
profile, a reused key with new details or a duplicate booking: show the message and
re-review rather than resend. 409 on a decision or cancel means the question closed,
the booking is final or payment started, or the booking is briefly busy: read the
booking again instead of retrying. 503 indicates a dependency failure; it does not prove that an upstream action had
no effect. Read the current booking and pending decision before resubmitting an
answer or card setup request.

Hallpass, the task broker behind these routes, is a separate private service that
only this API calls. Its hallpass-tasks-v3 reference is served to staff accounts and
internal services at `/openapi/hallpass.json`; parents and the public cannot read it,
and Hallpass itself is never reachable from outside.

### Staff Hallpass runs

Current staff or ADMIN accounts acting as themselves can start and follow tasks
outside a family booking. A named machine key owned by that staff account can plan,
start, list, read, follow, watch and cancel runs; only the signed-in account can
answer a decision. Previews and delegated service requests are refused.

`GET /api/hallpass/settings` reports the deployment's USD cap and whether orders and
site accounts are enabled. A run places an order by default: omitted `maxSpend`
uses that cap. Explicit `rehearsal: true` or a zero `maxSpend` orders nothing and
stops at the checkout total. With a zero deployment cap, a full-order request is
refused; request a rehearsal explicitly. General tasks always require a person's
confirmation of the exact total before payment.

`POST /api/hallpass/plans` starts nothing: a catalog `sessionId` plans a camp booking;
a site's confirmed catalog match offers current sessions without guessing a kind.
`POST /api/hallpass/runs` uses one idempotency key per staff approval; identical
resends reconcile the same run, changed details conflict. Site accounts are sealed,
masked on reads, delivered only through Automatic mode and erased at the terminal
boundary/deadline; never repeat their values in instructions or logs.

Read `/api/hallpass/runs/{runId}` for current state, decisions, accounts and holds;
activity, masked live screen and Axiom trace have separate reads. A hold describes a
wait no requester answer can resolve; it is not a decision. Generated contracts are
at `/openapi/hallpass-runs.json` and `/openapi/hallpass-runs.ts`. Follow each operation's
current access and response contract, including the account-only decision route.

### Staff geocoding operations

All routes below require a current staff or ADMIN operator identity. Intelligence exposes the
workflow at `/geocoding`; the worker HTTP service remains health-only.

| Method | Route suffix under `/api/ingestion/geocoding` | Purpose |
|---|---|---|
| GET | `/status` | Fresh worker, credential, and reference readiness without waiting for location counts |
| GET | `/coverage?state=&city=&postalCode=` | Distinct location/entity counts, ZIP and precision coverage, pending/unresolved work, datasets, and worker status; counts come from a short-lived per-scope cache while worker/reference status stays current |
| GET | `/regions?state=` | US states and recorded city/ZIP options |
| POST | `/preview` | Read-only candidate/cost quote; no Geocodio calls |
| POST | `/jobs` | Confirm the quoted candidate set and lookup budget |
| GET | `/jobs?state=&limit=&offset=` | Paginated historical jobs |
| GET | `/jobs/{id}` | Job progress and outcomes |
| GET | `/jobs/{id}/items?limit=&offset=` | Paginated staff-only address/item outcomes |
| POST | `/jobs/{id}/cancel` | Stop unsent work and retain sent-request outcomes |

Preview takes `{scope:{state,city?,postalCode?}, mode:"gaps"|"refresh", rerunOf?}`.
State is required. Scope normalizes state names/codes and ZIP+4; city is a stored
label. Explicit non-US and conflicting state/ZIP evidence is excluded. A recognized
unqualified US ZIP permits US inference. Shared configurations count once across live
organizations, activities, and sessions; affected record counts remain separate.

Success uses `{data, success:true}`; a job response carries `data.job`.
The preview at `data` includes `fingerprint`, configuration/record counts, unique and reusable
addresses, exclusions, required lookups, `unitPriceMicrousd`, and
`estimatedCostMicrousd`. One micro-USD is one millionth of a dollar. The initial price
is 1,000 micro-USD per lookup ($1 per 1,000 lookups), before any shared provider free
credits. Actual billing may be lower. Preview never submits provider work.

Confirmation sends the same input plus `fingerprint` and `idempotencyKey` to `/jobs`.
The server recomputes the quote transactionally; changed candidates or pricing require
another preview. A repeated idempotency key for the same confirmation returns the same
job. The confirmed candidates and lookup budget stay fixed; later records need another
job. Reruns use `rerunOf` and a new preview/confirmation, preserving historical jobs.

Gap filling preserves source and accepted address-level coordinates and can reuse
successful results for unchanged addresses. Refresh also re-geocodes earlier Geocodio
results; source coordinates stay protected. The worker deduplicates normalized addresses,
records responses before applying coordinates, and rechecks live records at settlement.
Uncertain provider requests are never automatically resent. Authentication/quota
failures block new submissions. Reconcile uncertain billing before authorizing another
job; cancellation does not undo provider requests already sent.

Free ZIP/state approximation runs automatically after explicit reference import. It
needs no paid confirmation and updates coverage on the same page. Source coordinates
and accepted address-level geocoding remain protected. Coordinate precision and
provenance change atomically; derivation metadata is system-managed and cannot be
mapped as an ingestion contribution. Datasets and credentials are configured through
deployment, not these staff routes.

### Staff scraper monitoring

Staff and ADMIN accounts read durable scraper history through
`GET /api/ingestion/scraper-jobs`. Use `websiteOrigin` with an absolute HTTP(S) URL
without credentials to match its normalized origin across page paths and queries.
Host and scheme case, root slashes, and default ports normalize; scheme, subdomains,
`www`, and nondefault ports stay distinct. Path, query, and fragment do not scope
this filter. Use `websiteTarget` for exact normalized URL history across installations:
supply one absolute credential-free HTTP(S) URL of at most 8192 decoded bytes. It uses
the same normalization and root aliases as website status; remaining paths, path case,
and query values stay distinct. Verify that `data.websiteTarget` echoes the requested
normalized URL before treating history as exact. An absent or mismatched echo means the
API dependency is unavailable. Target history does not prove camp ownership or include
URLs removed from a camp. The existing `website` (`websiteUrl` alias) filter is a
case-insensitive substring, and combines with `websiteOrigin`, `websiteTarget`,
installation, kind, status, creation bounds, and pagination. Count and page share a read
snapshot, ordered by descending creation time then job ID. Each page request reads a
fresh snapshot, so new jobs can shift offsets. These monitoring reads do not submit work.

A failed job's `error` is a short failure code. A job fails without spending its
retries when a retry would repeat the failure: `training_mismatch`,
`family_review_required`, `root_page_missing`, or `crash_loop` (the worker died three
times in a row while running it). Filter with `status=failed` and
`error=<code>`. Once the fix is deployed, `POST /api/ingestion/scraper-jobs/{id}/retry`
queues each one again (201, `data.job`). The new job has the same installation, website,
kind, settings and attempt limit, at the current contract version. A failed job has at
most one retry: repeating the request returns it with 200 and queues nothing. A job that has not failed returns
409.

Use `GET /api/ingestion/scraper-jobs/website-status` with repeated `websiteUrl` values
for exact saved targets. Supply 1–50 absolute HTTP(S) URLs without credentials, each
at most 8192 decoded bytes. Invalid or over-limit batches return 400 without truncation.
`data.websites` includes every unique normalized target in first-seen order with nullable
`latest`, `completed`, and `active` jobs, including targets with no history. Queue rules
collapse repeated path slashes, trim non-root trailing slashes, sort raw query pairs,
and strip fragments; remaining paths and query values stay distinct. Root slash/no-slash
forms share status. Use the returned job's exact `website` for refreshes so artifact
ownership is preserved. Latest and completed select newest creation time then job ID;
active prefers running over paused or queued. Jobs retain the existing sanitized
monitoring projection and unsupported-contract reason.

A job whose attempt found a model provider unavailable has status `paused` (contract v5;
older jobs return to `queued`) and an `error` such as `Paused: TypeSafe credits
exhausted`. The paused attempt does not count toward `max_attempts`. Filter with
`status=paused`. `GET /api/ingestion/scrapers` lists each installation's
`provider_pauses` (provider: `typesafe`, `codex`, `openai`, `anthropic`, `google` for
Gemini, or `geonode` for the scraper's residential proxy; reason, `paused_at`, `probed_at`, `probe_error`, `probes`);
an installation claims no jobs while any remain. It resumes when an automatic health
probe passes, or when an operator runs `scrape-site resume <provider>` on that machine.

Trident workers run on the team's shared pool of Codex (ChatGPT) subscriptions.
`GET /api/ingestion/codex-accounts` lists them (`data.accounts`, without removed ones):
`status` is `pending_login`, `awaiting_approval` (show `device_url`, `user_code` and
`code_expires_at` to the teammate), `active`, `exhausted` (until `resets_at`),
`login_failed`, `login_required`, or `removing`, with `used_5h_pct`, `used_week_pct`,
`leased_by` and a redacted `last_error`. `POST /api/ingestion/codex-accounts` with
`{"label":"<1-80 characters>"}` adds one (201); `POST
/api/ingestion/codex-accounts/{id}/remove` removes one (202). `POST
/api/ingestion/codex-accounts/{id}/usage-ceiling` with `{"usage_ceiling_pct":50}`
(a whole percent from 1 to 100, or `null` to clear) returns 200 with the account and
works in any status before removal. While `used_5h_pct` or `used_week_pct` (total use,
including the owner's own) is at or above `usage_ceiling_pct`, the subscription stays
`active` and logged in but workers do not lease it: running calls finish and the worker
switches subscriptions, or pauses on `codex` when none is usable. It returns after a
fresh reading below the ceiling once that window resets (`resets_5h_at`,
`resets_week_at`), or at once when the ceiling is raised or cleared. Each installation in
`GET /api/ingestion/scrapers` reports the subscription it leases as `codex_account`
(`id`, `label`, `email`) or `null`. No response contains tokens or filesystem paths.

Each installation also lists every running job in `current_jobs` (oldest first, `[]`
when idle); `current_job` is the first of them.

Operators update a Trident installation to the latest main from the dashboard. Each
installation in `GET /api/ingestion/scrapers` reports `build` (`revision`, and
`main_revision`, `behind_main` and `checked_at` as its server last reported them, or
`null`) and `update`, its newest request or `null`. `POST
/api/ingestion/scrapers/{id}/update` updates to `build.main_revision` and `POST
/api/ingestion/scrapers/{id}/rollback` puts the previous build back; both return 202
with `data.request`, or 409 while another request is active (or, for update, before the
server reported main). A request's `status` is `pending`, `draining` (the worker
finishes its jobs; after about 10 minutes it is `refused` as busy), `running`, then
`succeeded`, `rolled_back`, `failed` or `refused` (for example `needs migration NNNN:
run make migrate-prepare`), with `from_revision`, `to_revision`, and a redacted
`message` and `log_tail`. A request running for over 15 minutes without a result shows
as `failed` with message `no result`.
