Files
krow_backend/docs/api-contract.md
2026-08-25 16:37:05 +05:30

1180 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Krow API Contract — v1
**Status: frozen for Phase 2C/2D implementation.** This document is derived
entirely from the Krow frontend repository (`krow-demo`) as it stands, and from
the Phase 1 schema in `migrations/000001_initial_schema.up.sql`. Nothing here is
aspirational: every endpoint exists because a call site exists, and every
semantic below was read out of `src/api/store.js` rather than designed.
The acceptance criterion for Phase 2D is narrow and testable:
> Replacing the transport inside `src/api/base44Client.js` — and changing no
> other frontend file — must leave the application behaving identically.
If implementing this contract requires editing `krowHooks.js`, a page or a
component, the contract is wrong and gets fixed here first.
---
## 1. API conventions
| Aspect | Decision |
| --- | --- |
| Base path | `/api/v1` |
| Resource naming | kebab-case plural (`/job-postings`, `/worker-profiles`). Mass nouns stay singular: `/staff`, `/evidence`, `/user-activity`. |
| Identifiers | `uuid` in the path. See §1.2 on legacy ids. |
| Content type | `application/json; charset=utf-8` both ways |
| Field naming | **snake_case, identical to the frontend's field names.** No renaming, no camelCase conversion. The one exception is `/me/preferences` — see §9. |
| Dates | ISO 8601 with offset (`2026-08-21T10:04:53.402788Z`). `created_date` / `updated_date` keep those names. |
| Partial updates | `PATCH`, shallow merge. There is no `PUT`. |
| Reserved query params | `sort`, `limit`, `offset`. Every other query param is a field filter (§6). No current column collides with these three. |
| Auth | **None in v1.** Every endpoint is unauthenticated. §9 documents the shape auth will take without implementing it. |
### 1.1 Why an envelope
The frontend's entity methods return bare values today: `list()` and `filter()`
return an array, `get()`/`create()`/`update()` return an object, `delete()`
returns `{ id }`. The API nonetheless wraps responses in `{ "data": … }`.
That is affordable precisely because `base44Client.js` is the one file allowed
to change: the shim unwraps with a single `.data` and the hooks above it never
see the envelope. What it buys is `meta.total`, which the bare shape has nowhere
to put — and the collection caps (§8) silently truncate today, which is a known
data-hiding bug the envelope makes fixable later without another contract change.
### 1.2 uuid vs. the frontend's string ids
Phase 1 gives every table a `uuid` primary key plus a nullable unique
`legacy_id text`. The frontend's existing ids (`jobposting_m1a2b3c001`,
`user_demo`) are `legacy_id` values, not uuids.
**The API accepts and returns `id` as the uuid.** Path lookups resolve a uuid.
Records seeded from `src/api/seed.js` carry their original string in
`legacy_id`, which is returned as a field but is never the addressable id.
This is invisible to the frontend, which treats ids as opaque strings and never
parses or constructs one — `makeId()` is called only inside `store.js`, which
the transport swap replaces. **Verified:** no page, hook or component builds an
id, pattern-matches one, or depends on its prefix.
---
## 2. Entity endpoints
Derived from the operation matrix in §10.1. **An operation with no call site
gets no endpoint.** `POST /job-postings/:id` does not exist because nothing in
the frontend deletes a job posting.
### Live — reachable from a mounted route today
| # | Method | Path | Purpose |
| --- | --- | --- | --- |
| 1 | `GET` | `/api/v1/job-postings` | List postings |
| 2 | `GET` | `/api/v1/job-postings/{id}` | One posting |
| 3 | `POST` | `/api/v1/job-postings` | Create a posting (incl. drafts) |
| 4 | `PATCH` | `/api/v1/job-postings/{id}` | Update a posting |
| 5 | `GET` | `/api/v1/job-applications` | List / filter applications |
| 6 | `POST` | `/api/v1/job-applications` | Create an application |
| 7 | `PATCH` | `/api/v1/job-applications/{id}` | Update — status, AI screening fields |
| 8 | `DELETE` | `/api/v1/job-applications/{id}` | Remove an application |
| 9 | `GET` | `/api/v1/ai-interviews` | List interviews |
| 10 | `POST` | `/api/v1/ai-interviews` | Record an interview result |
| 11 | `GET` | `/api/v1/staff` | List hires |
| 12 | `POST` | `/api/v1/staff` | Create a hire record |
| 13 | `PATCH` | `/api/v1/staff/{id}` | Update a hire (rating, endorsement) |
| 14 | `GET` | `/api/v1/worker-profiles` | List / filter profiles |
| 15 | `POST` | `/api/v1/worker-profiles` | Create a profile |
| 16 | `PATCH` | `/api/v1/worker-profiles/{id}` | Update a profile |
| 17 | `GET` | `/api/v1/courses` | List courses |
| 18 | `GET` | `/api/v1/courses/{id}` | One course |
| 19 | `POST` | `/api/v1/courses` | Create a course |
| 20 | `PATCH` | `/api/v1/courses/{id}` | Update a course |
| 21 | `GET` | `/api/v1/learning-paths` | List learning paths |
| 22 | `GET` | `/api/v1/role-categories` | List role categories |
| 23 | `POST` | `/api/v1/role-categories` | Create a role category |
| 24 | `GET` | `/api/v1/user-activity` | List / filter the activity log |
| 25 | `POST` | `/api/v1/user-activity` | Append an activity event |
| 26 | `GET` | `/api/v1/assignments` | List assignments |
| 27 | `POST` | `/api/v1/assignments` | Create an assignment |
| 28 | `GET` | `/api/v1/shift-records` | List shift records |
| 29 | `POST` | `/api/v1/evidence` | Submit challenge evidence |
| 30 | `PATCH` | `/api/v1/evidence/{id}` | Supervisor-verify evidence |
| 31 | `GET` | `/api/v1/me` | Current user |
| 32 | `PATCH` | `/api/v1/me` | Update current user |
| 33 | `GET` | `/api/v1/me/preferences` | Read preferences |
| 34 | `PATCH` | `/api/v1/me/preferences` | Merge preferences |
### Unreachable today — included deliberately (D6)
The calling code exists and compiles; only its route is unmounted. Excluding
these would leave the shim with methods that 404. See §11 (D6).
| # | Method | Path | Sole consumer |
| --- | --- | --- | --- |
| 35 | `GET` | `/api/v1/certifications` | `CertificationManager.jsx` ← `pages/Positions.jsx` *(unmounted)*, `pages/KrowIdentity.jsx` *(unmounted)* |
| 36 | `POST` | `/api/v1/certifications` | `CertificationManager.jsx` |
| 37 | `DELETE` | `/api/v1/certifications/{id}` | `CertificationManager.jsx` |
| 38 | `GET` | `/api/v1/evidence` | `useEvidenceList` — **zero consumers**; included only so the shim's `Evidence.list/filter` resolves |
### Not in v1
| Entity/op | Why |
| --- | --- |
| `GET /badges` | `useBadges` has **zero consumers**. Every badge the UI renders comes from `worker_profiles.earned_badges`. The table exists; nothing reads it. |
| `DELETE` on any resource but `job-applications` and `certifications` | No call site. |
| `POST /shift-records` | Nothing in the frontend creates one. See §11 (U1). |
| `GET /assignments/{id}`, `PATCH /assignments/{id}` | No call site — assignments are only listed and created. |
| `POST /ai-interviews/{id}` updates | No call site. |
| Multi-record transaction endpoints (hire, assign, screen-all, submit-challenge) | Deferred to Phase 3. See §12.1. |
---
## 2A. Owliver suggestions
`GET /api/v1/owliver/suggestions?page={surface}&query={typed}`
The one endpoint here that serves no resource. It answers "what could I usefully
ask on this page?" for the Owliver panel, which calls it while the user types —
so it reads no table, opens no transaction, calls no model, and its whole answer
is computed from a static catalogue in `internal/owliver`.
It is not on the public allowlist. Which readings exist depends on the caller's
role, so there is no anonymous answer to give.
### Request
| Parameter | Required | Meaning |
| --- | --- | --- |
| `page` | yes | A surface id from the closed page vocabulary — the same one `internal/definition` validates a definition's `pages:` against. Aliases (`hired`, `forge`, `new-position`) and loose spellings (`Talent Pool`) resolve to the canonical id. |
| `query` | no | What the user has typed so far. |
Any other parameter is `invalid_query`. **Nothing about the caller is accepted
here** — role, organization and user are read from the session, and a request
that names one is refused rather than ignored.
An unknown `page` is `invalid_query`, with the frontend's own wording:
`Unsupported page: {value}. Supported pages: {…}.` An absent, blank or
too-short `query` is **not** an error — there is simply nothing to rank yet.
### Response
```json
{
"data": {
"suggestions": [
{ "text": "Which position has the strongest pipeline?", "intent": "position-strength" },
{ "text": "Show hiring activity as a flow", "intent": "hiring-operations", "capability": "flow" }
]
}
}
```
`data.suggestions` is always an array — `[]` when nothing matches, never `null`
and never an error. There is no `meta`: the list is capped rather than paged.
| Field | Meaning |
| --- | --- |
| `text` | The question, as the user reads it. |
| `intent` | The **frontend capability id** the panel dispatches on, verbatim from the manifests in `src/components/ai-assistant/capabilities/`. Not a backend identifier, and never invented here. |
| `capability` | The section type to draw the answer as, from the closed `OWLIVER_CAPABILITIES` vocabulary. Present only when the query asked for one ("as a flow", "summarize"); absent otherwise. |
Nothing internal is exposed: no matching terms, no resource names, no scores, no
policy detail. `text` is always catalogue wording — no part of the query is
echoed back into a suggestion.
### Selection
Five stages, each of which only ever removes:
```
the page's catalogue → permission → relevance → deduplicate → top 3
```
- **Page.** Intents are keyed by surface, so a suggestion from another page
cannot appear. The same word answers differently per page by construction:
`pipeline` on `positions` is about which role converts, on `candidates` it is
the funnel the applicants are in.
- **Permission.** Each intent declares the resources it reads, and those are
checked against the policy table in §9A — *before* ranking, so a refused
reading is never scored. Two gates, not one: the role must be allowed the
operation, and for an organization-wide reading the role's rows must not be
narrowed. Talent may list job applications; talent may not be offered "which
position has the strongest pipeline?", because their view of that resource is
their own rows. No role list is written down here — see §9A.1.
- **Relevance.** Deterministic keyword ranking over the intent's own terms.
Exact token, then multi-word phrase, then prefix (so a half-typed word still
matches), then extension. Ties break on catalogue order, so the same request
always answers identically. A query naming only a section type ranks the
page's readings; once it names a subject, readings that merely *support* that
shape are dropped rather than used as padding.
- **Deduplicate.** One suggestion per intent id, and no two with the same text.
- **Cap.** Three. Nothing is added to reach three.
A query with fewer than two letters or digits after normalization returns `[]`.
### Normalization
The query is truncated to 200 characters, lower-cased, and reduced to letters,
digits and single spaces — every other character becomes a space rather than
being stripped, so nothing can be glued into a token that was not typed as one.
There is no injection surface to defend: the normalized text is compared against
a fixed table of literals and never reaches SQL, a template, a shell or a log
message. Hostile input is ranked like any other text, and can only ever produce
entries the catalogue already holds.
### Where the catalogue comes from
The frontend owns the vocabulary. Owliver's capabilities are declared per page
context in `src/components/ai-assistant/capabilities/`; `internal/owliver`
transcribes the id and the page, and adds the two things a manifest does not
carry — the words that mean a user is reaching for that reading, and the records
it reads. Same pattern as `internal/definition/vocabulary.go`, and for the same
reason: one vocabulary, named from both ends, with a test on this side that
fails when an id here names no capability there.
**No table, no migration.** The suggestions are derived from definitions that
already exist. Persistence would only be warranted if suggestions became
admin-managed, and nothing in the product asks for that today.
### Not covered
The seven workspace and configuration surfaces — `settings`, `workspace`,
`workspace-agents`, `workspace-skills`, `workspace-skill-configure`,
`skill-development`, `workspace-agent-configure` — are valid pages with no
entries. Their panel answers from the registries rather than from workforce
records, so there is nothing to rank a typed query against. They return `[]`,
which is the honest answer, not a validation error.
---
## 3. Request schemas
### 3.1 Create — `POST /{resource}`
Body is a **flat object of column values**, exactly the object the frontend
passes to `create()` today. No envelope on the request.
The server supplies `id`, `created_date`, `updated_date` and `org_id`. Any of
those in the body is **ignored, not rejected** — see §7.4 for why that
distinction is load-bearing.
Every column not supplied takes its schema default. Because the Phase 1 schema
declares `NOT NULL DEFAULT ''` / `'{}'` / `'[]'` on effectively every optional
column, a create with only the required fields succeeds and returns a fully
populated record — which is what `store.js` does today by returning whatever the
caller spread in.
Required per resource (everything else optional):
| Resource | Required |
| --- | --- |
| `job-postings` | `title` (non-blank) |
| `job-applications` | `job_posting_id`, `applicant_name` (non-blank), `email` |
| `ai-interviews` | `application_id`, `job_posting_id` |
| `staff` | `name` (non-blank), `email`, `hire_date` |
| `worker-profiles` | `full_name` (non-blank), `email` |
| `courses` | `title` (non-blank) |
| `role-categories` | `name` |
| `certifications` | `name` |
| `user-activity` | `event_type` (non-blank) |
| `assignments` | `job_posting_id`, `worker_email`, `starts_at` |
| `evidence` | `type`, `worker_email` |
### 3.2 Update — `PATCH /{resource}/{id}`
Body is a **partial** object. Only the keys present are written; every other
column is left alone. This mirrors `store.js`'s
`{ ...existing, ...data, updated_date: now }` exactly.
- A key present with `null` sets the column to `NULL` (and is rejected if the
column is `NOT NULL`).
- A key absent is not touched.
- `id`, `created_date` and `org_id` in the body are ignored.
- `updated_date` is always set server-side to `now()`, overriding any supplied
value — `store.js` does the same by placing it after the spread.
**There is no deep merge.** `store.js` shallow-merges, so `PATCH` with
`{"vetting_criteria": {"experience": 30}}` **replaces** the whole object rather
than merging into it. Preserving this matters: `useUpdateWorkerProfile` sends
whole recomputed arrays (`completed_courses`, `earned_badges`, `capabilities`),
and a deep merge would append instead of replace.
### 3.3 Delete — `DELETE /{resource}/{id}`
No body.
---
## 4. Response schemas
### 4.1 Single record
```json
{ "data": { "id": "…", "title": "Bartender", "created_date": "…", … } }
```
Returned by `GET /{r}/{id}`, `POST /{r}`, `PATCH /{r}/{id}`.
The record is **complete** — every column, including defaults the client never
sent. `store.js` returns a structured clone of the stored record, so the client
already relies on getting the whole thing back (`useHireCandidate` reads
`updated.status`; `useAssignWorkers` mutates `record.application_id` on the
returned assignment).
### 4.2 Collection
```json
{
"data": [ { … }, { … } ],
"meta": {
"total": 214,
"limit": 100,
"offset": 0,
"returned": 100,
"truncated": true
}
}
```
`truncated` is `true` when `total > offset + returned`. Nothing reads it yet;
it exists so the silent-truncation bug (§12.2) is fixable without a contract
change.
### 4.3 Delete
```json
{ "data": { "id": "…" } }
```
Matching `store.js`'s `return { id }`.
---
## 5. Error format
```json
{
"error": {
"code": "not_found",
"message": "JobPosting 7c9e… not found",
"details": {}
}
}
```
| Code | HTTP | When |
| --- | --- | --- |
| `unauthorized` | 401 | No valid session cookie, or a failed sign-in. See §9. |
| `forbidden` | 403 | Authenticated, but the caller's role does not permit the operation. See §9A.2. |
| `rate_limited` | 429 | Too many failed sign-in attempts. Carries `Retry-After`. |
| `not_found` | 404 | `GET`/`PATCH` on a missing id — including a row outside the caller's organization or, for talent, not their own |
| `validation_failed` | 422 | Body violates a column constraint. `details` maps field → reason. |
| `invalid_query` | 400 | Unknown filter field, malformed `sort`, non-integer `limit` |
| `conflict` | 409 | Unique violation (e.g. a second application for the same `(job_posting_id, email)`) |
| `internal` | 500 | Anything else. `message` is generic; the detail goes to the log with a request id. |
### 5.1 The `message` field is load-bearing
`store.js` throws `new Error(\`${name} ${id} not found\`)`, and the shim must
reproduce a thrown `Error` for a missing record because React Query's `isError`
path and several `.catch(() => …)` fallbacks depend on it. The shim converts any
non-2xx into `throw new Error(body.error.message)`.
**Consequence for `not_found`:** the message must read
`"<EntityName> <id> not found"` using the frontend's PascalCase entity name, not
the table name. Nothing parses it today, but it appears in console output and in
`PageNotFound`'s diagnostics, so keeping it identical costs nothing.
---
## 6. Filtering semantics
`store.js`:
```js
function matches(record, query) {
return Object.entries(query).every(([key, want]) => {
const got = record?.[key];
if (Array.isArray(want)) return want.includes(got);
return got === want;
});
}
```
That is the entire filter language. It is reproduced exactly:
| Behaviour | Rule |
| --- | --- |
| Combination | **AND** across every field. `.every()`. |
| Scalar | Strict equality. `WHERE col = $1` |
| Array | Membership. `WHERE col = ANY($1)`. Expressed as a repeated query param: `?status=applied&status=hired` |
| Operators | **None.** No `gt`, `lt`, `like`, `contains`, `between`. Adding one is a contract change. |
| Unknown field | `400 invalid_query`. `store.js` would return zero rows (every record's `undefined !== want`); a 400 is strictly better and cannot break a caller, because no caller sends an unknown field. |
| Array-valued columns | Not filterable. `got === want` is a reference comparison against a JS array and is always false, so `filter({ skills: 'Bartending' })` returns nothing today. The API must **not** silently improve this into a containment query. |
| `null` | Not expressible. No caller filters on null. |
| Empty query | Equivalent to `list()`. |
| Type coercion | Query params arrive as strings; the server coerces per column type before comparing. **Every filter in use today is on a `text`/`citext` column**, so this is currently a no-op — but `?ai_score=90` must compare as an integer, not a string. |
### 6.1 Filters actually in use
Only four call sites filter. Any other combination is untested and unsupported
in v1.
| Endpoint | Filter | Call site |
| --- | --- | --- |
| `GET /job-applications` | `job_posting_id` | `krowHooks.js:85` |
| `GET /worker-profiles` | `email` | `krowHooks.js:554` |
| `GET /evidence` | `worker_email` | `krowHooks.js:633` *(hook has no consumers)* |
| `GET /user-activity` | `user_email` | `pages/Profile.jsx:28` *(unmounted — D6)* |
`email` and `worker_email` map to `citext` columns, so matching is
case-insensitive **server-side** where `store.js` was case-sensitive. This is a
deliberate, safe divergence: `useAssignWorkers` already lowercases both sides
before comparing (`krowHooks.js:445`), so the frontend's own intent is
case-insensitive, and `worker_profiles` has a `UNIQUE (org_id, email)` that makes
a case-variant duplicate impossible to create.
---
## 7. Sorting semantics
```js
function applySort(records, sort) {
if (!sort) return records;
const desc = sort.startsWith('-');
const field = desc ? sort.slice(1) : sort;
return [...records].sort((a, b) => {
const av = a?.[field]; const bv = b?.[field];
if (av === bv) return 0;
if (av === undefined || av === null) return 1;
if (bv === undefined || bv === null) return -1;
const result = typeof av === 'number' && typeof bv === 'number'
? av - bv : String(av).localeCompare(String(bv));
return desc ? -result : result;
});
}
```
| Rule | Behaviour |
| --- | --- |
| Syntax | `?sort=-created_date` — a leading `-` means descending, otherwise ascending. One field only. |
| Default | `-created_date` on every collection. Applied when `sort` is absent. |
| Empty string | `?sort=` returns rows in insertion order, unsorted. Preserved as "no `ORDER BY`". |
| Unknown field | `400 invalid_query`. |
### 7.1 NULLs sort last in **both** directions
The two null branches `return` before `desc` is applied, so a null is greater
than everything ascending *and* descending. This is not a bug to fix — it is
observable behaviour that the SQL must reproduce:
```sql
ORDER BY col DESC NULLS LAST -- descending
ORDER BY col ASC NULLS LAST -- ascending, note: NOT the SQL default
```
PostgreSQL's default is `NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`, so
the descending case **must** be written explicitly or nulls will surface at the
top of every list where `store.js` put them at the bottom.
### 7.2 Non-numeric comparison is lexicographic
When either value is not a number, `store.js` compares
`String(av).localeCompare(String(bv))`. For the sorts actually in use this is
equivalent to a SQL text sort:
- `-created_date` — ISO 8601 strings sort lexicographically the same as
chronologically. Sorting the `timestamptz` column directly is correct.
- `-ai_score`, `-krow_score` — both numeric on both sides, so `av - bv`. Sorting
the `int` column is correct.
No other sort field is used, so no collation edge case is reachable in v1.
### 7.3 Sort stability
`Array.prototype.sort` is stable in every engine the app targets, so equal keys
keep insertion order. PostgreSQL guarantees no such thing. **A tiebreaker is
required:** every `ORDER BY` appends `, id` so repeated identical requests
return the same order. Without it, paginated lists can drop or duplicate rows
between pages.
### 7.4 Insertion order is newest-first
`create()` does `table().unshift(record)` — new records go to the **front**. With
the default `-created_date` sort this is already the outcome, so it is only
observable when two records share a timestamp or when `sort` is empty. The `, id`
tiebreaker does not reproduce it exactly; the divergence is limited to
same-millisecond inserts and nothing depends on it.
**Related:** `create()` spreads `...data` *after* the generated `id` and
`created_date`, so a caller-supplied `id` or `created_date` **wins** today. No
caller does this, which is why §3.1 ignores those fields rather than honouring
them — but a seeder that needs to preserve ids must write them directly, not
through `POST`.
---
## 8. Pagination semantics
**The frontend does not paginate over the network.** This is the single most
important thing not to redesign.
Every collection is fetched once, whole, up to a hard cap, and every page then
filters, sorts, searches and paginates **in the browser** over that array.
`components/ds/Pagination.jsx` is a pure client control; `DataTable` slices
`sortedRows` locally. `DataTable` does accept a `serverPaginated` prop — **no
page passes it.**
| Rule | Behaviour |
| --- | --- |
| `?limit=` | Max rows returned. Defaults to the per-endpoint value in §8.1. |
| `?offset=` | Rows to skip. Defaults to `0`. **No current caller sends it.** |
| Cap | `limit` is clamped to `1000`. Nothing requests more than 500. |
| Empty result | `{"data": [], "meta": {"total": 0, …}}` and **HTTP 200**. Never 404. `store.js` returns `[]`, and every consumer defaults with `= []`. |
### 8.1 Per-endpoint default limits — these are not arbitrary
Each default is the exact second argument at the call site. Changing one changes
what the UI shows, because the truncation happens before any client-side filter
runs.
| Endpoint | Default `limit` | Default `sort` | Call site |
| --- | --- | --- | --- |
| `/job-postings` | 100 | `-created_date` | `krowHooks.js:68` |
| `/job-applications` | **200** | **`-ai_score`** | `krowHooks.js:85-86` |
| `/ai-interviews` | 100 | `-created_date` | `krowHooks.js:93` |
| `/shift-records` | **500** | `-created_date` | `krowHooks.js:108` |
| `/staff` | 100 | `-created_date` | `krowHooks.js:115` |
| `/role-categories` | 100 | `-created_date` | `krowHooks.js:132` |
| `/certifications` | 200 | `-created_date` | `krowHooks.js:139` |
| `/user-activity` | **500** | `-created_date` | `krowHooks.js:176` |
| `/user-activity` (filtered) | **20** | `-created_date` | `pages/Profile.jsx:28` |
| `/courses` | 200 | `-created_date` | `krowHooks.js:346` |
| `/assignments` | 500 | `-created_date` | `krowHooks.js:391` |
| `/learning-paths` | 100 | `-created_date` | `krowHooks.js:534` |
| `/badges` | 200 | `-created_date` | `krowHooks.js:538` *(no consumer)* |
| `/worker-profiles` | 500 | **`-krow_score`** | `krowHooks.js:583` |
| `/worker-profiles` (filtered) | **1** | `-created_date` | `krowHooks.js:554` |
| `/evidence` | 200 | `-created_date` | `krowHooks.js:633-634` |
The shim sends these explicitly rather than relying on server defaults, so the
numbers stay visible at the call site where they already live.
### 8.2 Latency
`store.js` injects 140 ms on reads and 220 ms on writes so loading states are
real. Real network latency replaces it. The loading states are already designed
and exercised — but they have only ever seen *uniform* latency, so Phase 2D
should test slow and failing responses, not just successful ones.
---
## 9. Current-user / auth contract
**Authentication is required.** Every endpoint in this document except
`GET /health`, `POST /api/v1/auth/login` and `POST /api/v1/auth/logout` answers
`401` with `{"error":{"code":"unauthorized","message":"authentication required"}}`
unless the request carries a valid session cookie.
`base44.auth` surface in use: `me()` ×11, `logout()` ×5, `updateMe()` ×3,
`preferences()` ×2, `updatePreferences()` ×1, `redirectToLogin()` ×1, plus
`login()`.
### `POST /api/v1/auth/login`
Public. Body: `{"email": "…", "password": "…", "remember_me": false}`.
`200` returns the user, in the same shape as `GET /me`, and sets a session
cookie: `krow_session=<opaque token>; Path=/; HttpOnly; SameSite=Lax`, plus
`Secure` when `APP_ENV != development`, with `Max-Age` matching the session
lifetime — 12 hours normally, 30 days with `remember_me`.
**The token is never in the response body.** It exists in the `Set-Cookie`
header and in the browser's cookie store; PostgreSQL holds only its SHA-256.
`422` when `email` or `password` is missing. `429` when too many failed attempts
have been made against this email or from this address. Every credential failure
— wrong password, unknown email, no password set, suspended account — is the
same `401` with the same body, so the endpoint cannot be used to discover which
addresses are registered.
### `POST /api/v1/auth/logout`
Public and idempotent. Revokes the session behind the cookie if there is one,
expires the cookie, and answers `200` with `{"data":{"status":"signed_out"}}` —
including when the cookie is absent, stale or was never valid.
### `GET /api/v1/me`
Returns the user behind the session cookie. Response `data`:
```json
{
"id": "…uuid…",
"legacy_id": "user_demo",
"full_name": "Alex Rivera",
"email": "demo@krow.app",
"role": "admin",
"account_type": "employer",
"created_date": "2026-06-01T00:00:00.000Z",
"preferences": { "owliverDefault": true, "compactDensity": false, "emailDigest": true }
}
```
`preferences` is **embedded in the user object**, because `krowHooks.js:42`
reads `user?.preferences` directly.
### `PATCH /api/v1/me`
Shallow merge, returns the full updated user. Used by `layouts/Layout.jsx:73`
(`account_type` role switch, unmounted) and `pages/admin/Profile.jsx`.
### `GET` / `PATCH /api/v1/me/preferences`
**The one place field naming diverges from the database.** The frontend uses
camelCase keys; Phase 1 stores three of them as snake_case columns plus an
`extra` jsonb blob:
| API key (camelCase) | Column |
| --- | --- |
| `owliverDefault` | `user_preferences.owliver_default` |
| `compactDensity` | `user_preferences.compact_density` |
| `emailDigest` | `user_preferences.email_digest` |
| everything else | merged into `user_preferences.extra` (jsonb) |
"Everything else" is not a hypothetical: `customSkills` and `customAgents` — every
account-authored skill and agent definition — live in this blob today, written
by `WorkspaceSkills.jsx`, `SkillEditor.jsx`, `OwliverSkillEditor.jsx` and
`useAgents.js`. They are opaque to the API in v1 and are promoted to real tables
in a later phase.
`PATCH` **shallow-merges** the supplied keys into the existing preferences and
returns the merged object.
### The `{ persisted }` return shape
`auth.updatePreferences()` returns `{ user, persisted, error }`, not a bare user.
This exists because a swallowed `QuotaExceededError` silently lost
account-authored skills — see the comment at `base44Client.js:persistUser`. Over
HTTP a failed write is already a non-2xx, so the shim synthesises
`{ user: data, persisted: true, error: null }` on success and lets the throw
path handle failure. `lib/skills/saveFeedback.js` is the sole consumer and needs
no change.
### `logout()` / `redirectToLogin()`
Client-side only in v1 — clear local session state and navigate. **No endpoint.**
Nothing is called over the network.
---
## 9A. Authorization contract
Authentication answers *who is calling*; this section answers *what they may
do*. Both are implemented. Every rule below is enforced in the API and covered
by a test — nothing here is aspirational.
### 9A.1 The authority
`users.role`, and only `users.role`. Three values, fixed by the
`users_role_check` constraint in migration 000001:
| Role | Who |
|---|---|
| `admin` | runs the platform for the organization |
| `employer` | runs the organization's hiring and workforce |
| `talent` | a worker, acting for themselves |
**`account_type` is not an authorization field.** It is a display attribute the
user may change on themselves through `PATCH /me`, and nothing in the API reads
it to make a decision. Neither is anything in a request body, a header, or the
browser: the role is read from the `users` row named by the session.
An unrecognised role authorizes nothing.
### 9A.2 401, 403 and 404
Three refusals, and the difference between them is load-bearing:
| Status | Code | Meaning |
|---|---|---|
| `401` | `unauthorized` | No valid session. The caller is nobody. |
| `403` | `forbidden` | The caller is known and their **role** does not permit the operation. |
| `404` | `not_found` | The **row** is outside the caller's organization or, for talent, is not theirs. |
The role check runs in the handler before any query, so it is always `403` and
never reveals whether a row exists. Organization and ownership are SQL
predicates, so a row outside them is simply absent — a caller cannot tell "it
exists and is not yours" from "it does not exist". The `403` message names
neither the caller's role nor the roles that would have worked.
`DELETE` continues to answer `200` whether or not a row matched (§12.7), which
discloses nothing either way.
### 9A.3 Permission matrix
Read `own` as "restricted to their own rows by a SQL predicate" — see §9A.4.
| Resource | Admin | Employer | Talent |
|---|---|---|---|
| `/me`, `/me/preferences` | R U | R U | R U |
| `job-postings` | R C U | R C U | **R** *(active only)* |
| `job-applications` | R C U D | R C U D | **R C** *(own)* |
| `ai-interviews` | R C | R C | **R C** *(own)* |
| `staff` | R C U | R C U | — |
| `worker-profiles` | R C U | R C U | **R C U** *(own)* |
| `assignments` | R C | R C | **R** *(own)* |
| `shift-records` | R | R | **R** *(own)* |
| `courses` | R C U | R | R |
| `learning-paths` | R | R | R |
| `role-categories` | R C | R C | R |
| `certifications` | R C D | R C | R |
| `user-activity` | R C | R C | **R** *(own)* C |
| `evidence` | R C U | R C U | **R C** *(own)* |
| `badges` | — | — | — |
Two admin-only operations, and both have a reason beyond seniority:
- **`POST`/`PATCH /courses`** — a course with a NULL `org_id` is the shared
platform library, visible to every tenant, so a write here can reach beyond
the writer's own organization.
- **`DELETE /certifications/{id}`** — deleting one changes what every existing
posting that required it means.
`badges` has no endpoints at all (`Ops: 0`); its empty policy is written down so
the resource is deliberately closed rather than merely forgotten.
### 9A.4 Ownership
For a talent caller, reads and writes carry a second predicate beside the
organization scope, in the same `WHERE` clause:
| Resource | Predicate |
|---|---|
| `worker-profiles` | `user_id = <session user id>` |
| `job-applications` | `email = <session email>` |
| `assignments` | `worker_email = <session email>` |
| `shift-records` | `worker_email = <session email>` |
| `evidence` | `worker_email = <session email>` |
| `user-activity` | `user_email = <session email>` |
| `ai-interviews` | `application_id IN (SELECT id FROM job_applications WHERE org_id = … AND email = <session email>)` |
| `job-postings` | `status = 'active'` — visibility rather than ownership |
It is a predicate rather than a filter over fetched rows, which matters for more
than tidiness: `count(*)` runs over the same clause, so the `meta.total` a talent
caller sees is their own count and not the organization's.
Admin and employer are not row-scoped. Two employers in one organization see and
edit the same rows; that is what an operator console is.
### 9A.5 Server-owned identity
Six columns are filled in from the session and ignored if present in a request
body. They are the columns any ownership rule rests on, so a caller who could
set them could defeat the rule with the same request it constrains.
| Column | Filled with | When |
|---|---|---|
| `job_postings.created_by` | session user id | always |
| `user_activity.user_id` | session user id | always |
| `user_activity.user_email` | session email | always |
| `user_activity.user_name` | session full name | always |
| `user_activity.account_type` | session account type | always |
| `worker_profiles.user_id` | session user id | **talent callers only** |
Two more are overridden for talent callers specifically, and left writable for
operators: `job_applications.email` and `evidence.worker_email`.
The distinction is between *who acted* and *who the row is about*. `created_by`
and the `user_activity` columns record the actor, so they are the session user
whoever that is. The others record the subject — and when an admin creates a
candidate's worker profile or files an application on their behalf, the subject
is the candidate, not the operator. Deriving those unconditionally would file
every candidate's record under whoever typed it in.
`org_id` remains server-owned on every resource, as it has been since Phase 2C.
### 9A.6 Not implemented
No permissions table, no policy engine, no per-record ACL, no role hierarchy and
no delegation. Authorization is a role, an organization and an ownership
predicate. Employer and Talent have no frontend surface yet; the backend
enforces their rules regardless, because an API that is only as safe as its
client is not safe.
---
## 10. Frontend → API → database mapping
### 10.1 Entity operation matrix
Derived by enumerating every `entities.<Name>.<op>(` call site in `src/`. There
are exactly six files that touch the seam.
| Entity | list | filter | get | create | update | delete | Frontend consumers |
| --- | :-: | :-: | :-: | :-: | :-: | :-: | --- |
| JobPosting | ✅ | — | ✅ | ✅ | ✅ | — | `useJobPostings` (18), `useJobPosting`, `useCreateJobPosting`, `useUpdateJobPosting`, `useGenerateJobDescription` |
| JobApplication | ✅ | ✅ | — | ✅ | ✅ | ✅ | `useApplications` (15), `useCreateApplication`, `useUpdateApplication`, `useScreenCandidate`, `useScreenAllCandidates`, `useHireCandidate`, `useAssignWorkers`, `useMarkInterviewReady`, `CandidateCard.jsx`, `admin/Candidates.jsx`, `PositionDetail.jsx` |
| AIInterview | ✅ | — | — | ✅ | — | — | `useInterviews` (8), `useCreateInterview` |
| Staff | ✅ | — | — | ✅ | ✅ | — | `useStaff` (12), `useHireCandidate`, `useUpdateStaff` |
| WorkerProfile | ✅ | ✅ | — | ✅ | ✅ | — | `useWorkerProfiles` (13), `useWorkerProfile` (9), `useSubmitChallenge`, `useUpdateWorkerProfile` ⚠️ |
| Course | ✅ | — | ✅ | ✅ | ✅ | — | `useCourses` (10), `useCourse`, `useCreateCourse`, `useUpdateCourse` |
| LearningPath | ✅ | — | — | — | — | — | `useLearningPaths` → `CourseDetail.jsx` |
| RoleCategory | ✅ | — | — | ✅ | — | — | `useRoleCategories` → `CreatePosition.jsx`, `KrowAssistant.jsx` |
| Certification | ✅ | — | — | ✅ | — | ✅ | `CertificationManager.jsx` ⚠️, `KrowIdentity.jsx` ⚠️ |
| UserActivity | ✅ | ✅ | — | ✅ | — | — | `useUserActivity` (6), `logActivity` (`userTracking.js`), `Profile.jsx` ⚠️ |
| Evidence | ✅❌ | ✅❌ | — | ✅ | ✅ | — | `useSubmitChallenge`, `useVerifyEvidence` ← `ChallengeRunner` ← `CourseDetail.jsx`. `useEvidenceList` has **no consumers**. |
| Assignment | ✅ | — | — | ✅ | — | — | `useAssignments` (6), `useAssignWorkers` |
| ShiftRecord | ✅ | — | — | — | — | — | `useShiftRecords` → `KrowAssistant.jsx`, `SkillSurface.jsx` |
| Badge | ✅❌ | — | — | — | — | — | `useBadges` — **no consumers** |
| User | — | — | — | — | — | — | Never via the entity API. Only `auth.me` / `updateMe` / `preferences`. |
⚠️ = only reachable from an unmounted route (D6). ❌ = call site exists but the
hook has no consumer. `(n)` = number of importing files.
**Note on `Evidence.list`:** unreachable even if `useEvidenceList` gained a
consumer — the hook is `enabled: !!workerEmail`, so the `.list()` branch cannot
execute.
### 10.2 Resource → table mapping
Every mapping is 1:1 against Phase 1. **No schema change is required.**
| API resource | Frontend entity | Table | PK | Foreign keys |
| --- | --- | --- | --- | --- |
| `/job-postings` | JobPosting | `job_postings` | `id` uuid | `org_id`, `created_by`→`users` |
| `/job-applications` | JobApplication | `job_applications` | `id` uuid | `org_id`, `job_posting_id`, `worker_profile_id` |
| `/ai-interviews` | AIInterview | `ai_interviews` | `id` uuid | `org_id`, `application_id`, `job_posting_id` |
| `/staff` | Staff | `staff` | `id` uuid | `org_id`, `application_id`, `job_posting_id`, `worker_profile_id` |
| `/worker-profiles` | WorkerProfile | `worker_profiles` | `id` uuid | `org_id`, `user_id` |
| `/courses` | Course | `courses` | `id` uuid | `org_id` *(nullable — platform library)* |
| `/learning-paths` | LearningPath | `learning_paths` | `id` uuid | `org_id` *(nullable)* |
| `/role-categories` | RoleCategory | `role_categories` | `id` uuid | `org_id` |
| `/certifications` | Certification | `certifications` | `id` uuid | `org_id` |
| `/user-activity` | UserActivity | `user_activity` | `id` bigint identity | `org_id`, `user_id`. `position_id`/`application_id`/`candidate_id`/`interview_id` are **unconstrained uuids by design** — an activity row must survive deletion of what it describes. |
| `/evidence` | Evidence | `evidence` | `id` uuid | `org_id`, `course_id`, `worker_profile_id` |
| `/assignments` | Assignment | `assignments` | `id` uuid | `org_id`, `job_posting_id`, `application_id`, `worker_profile_id` |
| `/shift-records` | ShiftRecord | `shift_records` | `id` uuid | `org_id`, `staff_id`, `assignment_id`, `job_posting_id` |
| *(none)* | Badge | `badges` | `id` uuid | `org_id` |
| `/me` | User | `users` + `user_preferences` | `id` uuid | `org_id` |
### 10.3 Organization scope
Every table carries `org_id`. **v1 resolves it to the single seeded
organization** — there is no tenant in the request, because there is no auth.
The repository layer must nonetheless take `org_id` as a parameter from day one
rather than defaulting it inside a query. When auth lands, the only change is
where the value comes from. A query that hardcodes the org is a query that has
to be rewritten.
`courses.org_id` and `learning_paths.org_id` are nullable — `NULL` means the
shared platform library. Reads must match `org_id = $1 OR org_id IS NULL`.
### 10.4 Enum mapping
All fifteen Phase 1 enums pass through as their literal string values. No
translation layer, no integer codes. The frontend compares
`status === 'ai_screened'` against the raw string and `StatusBadge.jsx`'s
`STATUS_MAP` is keyed on it.
| Column | Values |
| --- | --- |
| `job_postings.status` | `draft` `active` `paused` `closed` |
| `job_postings.priority` | `urgent` `high` `normal` |
| `job_postings.english_required`, `job_applications.english_level` | `basic` `conversational` `fluent` `native` |
| `job_applications.status` | `applied` `ai_screened` `shortlisted` `interview` `hired` `rejected` `assigned` |
| `ai_interviews.verdict` | `hire` `maybe` `no` |
| `staff.status` | `onboarding` `active` `inactive` |
| `staff.profile_tier` | `Beginner` `Cross-Trained` `Skilled` *(capitalised — matches `STATUS_MAP`)* |
| `assignments.status` | `active` `completed` `cancelled` |
| `shift_records.status` | `present` `late` `absent` `no_show` |
| `courses.status` | `active` `inactive` |
| `courses.target_level` / `required_level` | `beginner` `intermediate` `advanced` `expert` |
| `evidence.type` | `roleplay` `video` `photo_identify` |
| `evidence.ai_verdict` | `verified` `needs_work` `failed` |
| `badges.level` | `bronze` `silver` `gold` `platinum` |
| `badges.verification_status` | `pending` `verified` `expired` |
An invalid enum value is `422 validation_failed`, not a silent coercion.
---
## 11. Deferred decisions
### D2 — Is `company` a real entity? **DEFERRED. Does not affect this API.**
`company` is free text on `job_postings` (`positionModel.js:74` defaults it to
`''`, `:125` trims it). It is displayed (`PositionDetail.jsx:298`), used in
Owliver's summary lines, and read through a fallback chain in
`hiringRecords.js:39`. It is **never** grouped by id, joined, or given its own
route or hook. No `Company` entity exists.
`hiringRecords.js:39` reads `s.company` on a Staff record — but no Staff record
carries one; it is a defensive `||` fallback that always resolves to
`posting?.company`. **This is not a schema contradiction.**
**Decision:** keep `company text NOT NULL DEFAULT ''` on `job_postings`. No
`clients` table, no endpoint, no change. Promoting it later adds a resource and
a nullable FK; it does not alter any endpoint defined here.
### D3 — Are `assigned` / `rejected` pipeline stages? **DEFERRED. Does not affect this API.**
`STAGE_ORDER = ['applied','ai_screened','shortlisted','interview','hired']`,
duplicated verbatim in `hiringRecords.js:99`, `admin/positionInsights.js:9` and
`skills/dataResolver.js:112`. Funnels count `STAGE_ORDER.indexOf(a.status) >= from`.
`indexOf` returns `-1` for `assigned` and `rejected`, and `-1 >= 0` is false for
every stage — so **both statuses are excluded from every funnel count**, including
`applied`. Meanwhile `assigned` is written by `useAssignWorkers`
(`krowHooks.js:462` on create, `:466` on update) and `rejected` by `CandidateCard.jsx:118`.
**Decision:** the enum already carries all seven values. The API stores and
returns `status` verbatim and **never filters, reinterprets or normalises it**.
The funnel exclusion is a live frontend bug in `lib/`, which Phase 2 does not
touch. It becomes a backend decision only when aggregation moves server-side.
Flagged, not fixed — fixing it here would change what the UI displays, which is
out of scope.
### D6 — Do the unmounted pages come back? **DEFERRED as a product question; three endpoints included regardless.**
Fourteen page files plus `layouts/Layout.jsx` are imported by `App.jsx` and
mounted on no route: `Overview`, `Positions`, `Candidates`, `HiredHistory`,
`TalentPool`, `Apply`, `UserTracking`, `Analytics`, `Profile`, `WorkerProfile`,
`KrowIdentity`, `Owliver`, `EmployeeDashboard`, `DesignSystem`.
They are the *sole* consumers of three operations:
| Operation | Only reachable via |
| --- | --- |
| `Certification.list/create/delete` | `CertificationManager.jsx` ← `pages/Positions.jsx`; `pages/KrowIdentity.jsx` |
| `UserActivity.filter({user_email})` | `pages/Profile.jsx:28` |
| `WorkerProfile.update` via `useUpdateWorkerProfile` | `pages/KrowIdentity.jsx`, `pages/Owliver.jsx` |
The third needs no decision — `PATCH /worker-profiles/{id}` is already live via
`useSubmitChallenge` (`krowHooks.js:683`), which is reachable through
`ChallengeRunner` ← `CourseDetail.jsx` at `/admin/university/:id`.
**Decision:** include all three endpoints, marked *unreachable-today*. The
calling code exists in the repository and compiles; the endpoints are derived
from real call sites, not invented. Cost of including: three handlers over
tables that already exist. Cost of excluding: the shim needs conditional methods,
and reinstating them later is a contract change. **If D6 resolves to "delete the
pages", drop these three and the `certifications` table with them.**
### U1 — Where do shift records come from? **DEFERRED. Does not affect this API.**
`ShiftRecord` has exactly one seam operation anywhere in the frontend:
`.list('-created_date', 500)` at `krowHooks.js:108`. **No create, no update, no
delete.** Records exist only because `attendanceSeed.js` generates ~120 at boot.
Consumers are `KrowAssistant.jsx` and `SkillSurface.jsx` — Owliver's attendance
and overtime analysis.
**Decision:** `GET /api/v1/shift-records` only. **No `POST` in this contract** —
adding one would require inventing a write contract with no call site to derive
it from. The read side is fully determined and unblocked.
**Consequence for Phase 2C:** attendance and overtime are among the richest
features in the product and will be **empty in any real deployment** until a
rostering source exists (an import, an integration, or a scheduling UI — none of
which exist). The seeder must therefore reproduce `attendanceSeed.js`'s
deterministic generation, or Owliver's attendance skills have nothing to read.
### Still open, not required by this contract
**D4** — generic entity gateway vs. per-resource REST. This contract specifies
per-resource REST because that is what was asked for and it makes each endpoint's
operations explicit. A generic gateway
(`POST /entities/{Name}/{op}`) would be a thinner shim but would hide the fact
that, say, `JobPosting` has no delete. **D5** (where the 32 shipped definitions
live), **D7–D10** (RAG, embeddings, model routing, conversation persistence) —
none touch v1.
---
## 12. Known limitations
### 12.1 Multi-record writes are not transactional
Four flows write several records in a client-side loop, with no transaction and
no rollback. A failure halfway leaves the database inconsistent — this is true
today and **this contract does not fix it.**
| Flow | Writes | Site |
| --- | --- | --- |
| `useHireCandidate` | `PATCH` application → `POST` staff | `krowHooks.js:302-303` |
| `useAssignWorkers` | per worker: `POST` assignment → `POST`/`PATCH` application → `POST` activity | `krowHooks.js:421`, `:449`, `:466` |
| `useScreenAllCandidates` | one `PATCH` per candidate, sequentially | `krowHooks.js:254` |
| `useSubmitChallenge` | `POST` evidence → `PATCH` worker profile | `krowHooks.js:649-683` |
Phase 3 should collapse each into one endpoint and one transaction
(`POST /job-applications/{id}/hire`, `POST /job-postings/{id}/assignments`). That
**does** require touching `krowHooks.js`, which is why it is not in v1 — v1's
whole point is that the transport swap changes nothing above the seam.
Real latency makes this worse, not better: `useAssignWorkers` currently issues
3n sequential round-trips for n workers. At 220 ms of simulated latency that was
already slow; over a network it is slower, and a mid-loop failure is more likely.
### 12.2 Collection caps hide data silently
`limit` truncates without telling anyone. `worker-profiles` caps at 500 sorted by
`-krow_score`, so profile 501 is invisible to Talent Pool, to matching, and to
Owliver — with no indication. `meta.truncated` exists to make this fixable;
nothing consumes it yet.
### 12.3 All aggregation is client-side
Funnels, KPI tiles, charts, attendance rollups and overtime analysis all compute
in the browser over the capped arrays. Nothing is server-aggregated, and this
contract adds no aggregation endpoint. `shift_records` at 500 rows is the first
thing that breaks as data grows.
### 12.4 Search is substring matching in the browser
`admin/Candidates.jsx:91` concatenates `applicant_name`, `email`, `job_title` and
`skills` into one string and calls `.includes()` on the lowercased query. No
endpoint implements search, and none should in v1 — moving it server-side would
change which rows match.
### 12.5 Case sensitivity diverges, deliberately
`store.js` compares emails with `===`; `citext` compares case-insensitively. See
§6.1 for why this is safe and intended.
### 12.6 Sort stability is not guaranteed by the database
See §7.3. Every `ORDER BY` must append `, id`.
### 12.7 `DELETE` on a missing record returns 200
`store.js` filters the array and returns `{ id }` whether or not anything
matched — it never throws. The API reproduces this: `DELETE` is idempotent and
returns `200` with `{"data":{"id":"…"}}` even when the row was already gone.
**This is the one place the API is deliberately less strict than REST
convention.** `CandidateCard.jsx:132` and `admin/Candidates.jsx:57` delete inside
loops without checking, and a 404 would surface as an error toast where none
appears today.
### 12.8 Integrations are out of scope
`InvokeLLM` and `UploadFile` remain local (`aiEngine.js`, blob URLs). Nine AI
workflows run deterministically in the browser. No endpoint here replaces them;
that is the Owliver phase.
---
## 13. Phase 2C addendum — reconciliation and implementation notes
Phase 2C implemented this contract. Nothing in §1–§12 was redesigned; what
follows records the gaps implementation exposed and the four decisions taken
about them. Each was reported before it was acted on.
### 13.1 Schema gaps found and closed
Three columns the frontend reads or writes had no column in migration 000001.
All three were found by sweeping runtime write paths, not just seed data — which
is why the earlier seed-versus-schema diff missed two of them. **Migration
000001 was not modified**; the columns were added in `000002`.
| Column | Evidence | Resolution |
| --- | --- | --- |
| `job_applications.interview_id` | Written `AIInterviewModal.jsx:180`; read `CandidateExpandedDetails.jsx:41,142`; on 5 of 24 seed records | `uuid`, nullable, **no FK** — `app_devon` references `int_devon`, which no AIInterview record exists for, and nulling it would be transforming source data |
| `courses.training_outline` | Written `AddSkillTraining.jsx:103` ← `University.jsx`; read `challengeMeta.js:139`, `insights.js:177`, `admin.js:1547` | `text[] NOT NULL DEFAULT '{}'` — the writer produces a plain list of strings |
| `worker_profiles.score_breakdown` | Written via `recalcProfilePatch()` at `krowHooks.js:682`; **no reader consumes it from a profile** | `jsonb NOT NULL DEFAULT '{}'` — the column exists so the write lands rather than being discarded |
### 13.2 A constraint that rejected real data
`job_applications_screened_consistent` from migration 000001 —
`status = 'applied' OR screened_at IS NOT NULL OR ai_score = 0` — rejected **9 of
24** seeded applications: precisely the 9 AI-scored ones behind the "9 scored,
averaging 76" anchor. `screened_at` is never read or written anywhere in the
frontend; the column and the constraint came from the backend blueprint, not
from repository evidence. Dropped in migration `000003`. The column is retained,
nullable and unused.
All 53 CHECK constraints were then evaluated against all 245 seeded records on a
throwaway database. The other 52 hold.
### 13.3 Error codes
§5's table gains one code that only a developer can trigger:
| Code | HTTP | When |
| --- | --- | --- |
| `method_not_allowed` | 405 | The path exists but not under this method — `DELETE /job-postings/{id}`, `POST /shift-records` |
`net/http`'s own plain-text 404 and 405 replies are rewritten into the error
envelope, so every response from the API is JSON.
A resource with no item route at all — `assignments`, which is only listed and
created — answers **404**, not 405: 405 requires the path pattern to exist under
some other method.
### 13.4 Deliberate divergences from `store.js`
Three, all forced and all reported rather than silent:
1. **Email matching is case-insensitive.** `citext` columns compare without
regard to case where `store.js` used `===`. Safe: `useAssignWorkers` already
lowercases both sides (`krowHooks.js:445`) and `UNIQUE (org_id, email)` makes
a case-variant duplicate impossible. §6.1.
2. **`updated_date` is always populated.** Only job applications carry it in the
source; every other entity has none, and `store.js` therefore returns
`undefined`. The column is `NOT NULL`, so the seeder sets it to
`created_date` — the record has not been modified since creation. One visible
effect: `TalentDetailModal.jsx:66` reads `profile.updated_date || new Date()`
and will now show the seeded date rather than today.
3. **`_order` is dropped.** A positional index `seed.js:1326` uses while
building its course list. No column, no reader.
### 13.5 Unknown fields are rejected
§3.1 did not say what to do with a body field that maps to no column. The
implementation answers **422** with the offending field named in `details`.
Silently ignoring them is exactly how `interview_id`, `training_outline` and
`score_breakdown` would have been lost: the frontend would have written them,
the API would have returned 200, and the data would never have arrived. Rejecting
turns that class of gap into a failing request instead of missing data.
Server-owned fields (`id`, `org_id`, `created_date`, `updated_date`,
`legacy_id`) remain ignored rather than rejected, as §3.1 specifies.
### 13.6 Organization scoping from the session
Every request runs as the organization on the authenticated user's row, put on
the request context by the authentication middleware (`internal/httpserver`,
`authenticate`) alongside the identity itself (`internal/authctx`).
This replaced `devOrgMiddleware`, which put a fixed organization on every request
with no credential behind it. Because the organization was always threaded
explicitly through the service and repository boundaries rather than defaulted
inside the SQL, that swap changed one line and nothing below it — which is what
the arrangement existed for.
Nothing in the request influences it. The organization is read from the user's
row, the user from the session row, and the session from a cookie value the
client cannot forge without already holding it. A `user_id` or `org_id` in a
body or query string is ignored.
Scoping is enforced on reads and writes: a record belonging to another
organization is absent from collections and answers 404 by id. `courses` and
`learning_paths` match `org_id = $1 OR org_id IS NULL`, the NULL meaning the
shared platform library.
### 13.7 Seed data — verified counts
The Phase 2C brief cited "6 job postings, 22 job applications". Verified against
the source, the dataset is:
| | Source | Note |
| --- | --- | --- |
| Job postings | **8** | 6 `active`, 1 `paused`, 1 `closed` — "6" is the active count |
| Job applications | **24** | 24 distinct emails, 24 distinct (posting, email) pairs |
| Scored applications | 9, averaging **76.0** | matches the documented anchor |
| Hires | 3, averaging **94.3** | matches |
| Shift records | **115** | generated: 2 absent, 1 no-show, 5 late, 107 present |
| Everything else | 40 courses, 9 profiles, 9 role categories, 8 certifications, 15 activity, 4 interviews, 4 badges, 3 evidence, 2 learning paths, 0 assignments | |
`assignments` is empty in the source by design, and stays empty.