1061 lines
50 KiB
Markdown
1061 lines
50 KiB
Markdown
# 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. |
|
||
|
||
---
|
||
|
||
## 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.
|