Owliver could offer neither create. The Create Position flow worked and no chip anywhere suggested it, because the chip row is entirely the backend's static catalogue and no intent in it wrote anything. The gap was never in the frontend's trigger matching — every phrasing already routed. `employee_roles` is the supply side of `job_postings`. A posting is what the ORGANIZATION needs filled; this is what a WORKER says they do. They share a vocabulary and almost nothing else: "3 years" on a posting is a minimum an applicant must clear, and the same words here are what the person has. There is deliberately no foreign key between them — supply and demand already meet through `job_applications`, which carries the funnel, the interview and the outcome, and a second weaker link would disagree with it the first time somebody withdrew. NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company position" reads like it needs a client record. `organizations` is the TENANT — absent from the resource table, absent from the policy map, written only by the seeder — so creating a row there from a chat flow would provision a new tenant, and the position would carry an org_id the operator's session cannot see. The operator could never view the record they just created. That breaks I5 and I1 to add a feature nobody asked for. The client stays free text on the posting, per blueprint decision D2, and the flow simply offers the clients this organization already staffs for as chips. No schema change, no endpoint change. Create is operators-only, and that is an I1 decision rather than a deferral. The worker is named explicitly on the row and is deliberately NOT derived from the session, because an operator recording a role on somebody's behalf is the whole point of the flow. Granting talent the same Create would let a talent caller write a role under any worker_email in the tenant — the attribution hole Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate, which is in place now so the grant is one line when a talent console exists. `created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's Derived list. Both are required and the pairing is easy to miss: Derived fills the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly so a request body cannot set it in the first place. Without it, TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not by reading. The two catalogue intents carry PHRASE terms only. A bare "position" or "role" term scores 10, the same as every reading on that page, and wins the tie on declaration order — so a create chip would have arrived by evicting `positions-attention` from the exact ordered result TestPositionsSuggestions asserts. An offer to create something must not displace the reading a person actually asked for. Neither declares a Subject, on the precedent of `position-spec-steps`: a Subject would let the bare query "summarize" match through matchShape and survive filterOnTopic. Neither declares a Signal, so an empty composer still reports what the organization needs rather than proposing paperwork. Chip text is the coupling with nothing else holding it together: no page context declares `capabilities`, so every server suggestion dispatches as its own TEXT and is answered by whichever skill's trigger that text matches. A renamed chip would open nothing, silently. Asserted on the frontend side. The down migration drops `employee_role_status` and keeps `english_level`, which is shared with job_postings.english_required and job_applications.english_level. Rolled back and re-applied against the database to prove it, not asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
1222 lines
58 KiB
Markdown
1222 lines
58 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 |
|
||
| 35 | `GET` | `/api/v1/employee-roles` | List declared employee roles |
|
||
| 36 | `GET` | `/api/v1/employee-roles/{id}` | One employee role |
|
||
| 37 | `POST` | `/api/v1/employee-roles` | Record what a worker does |
|
||
| 38 | `PATCH` | `/api/v1/employee-roles/{id}` | Update a declared role |
|
||
|
||
### 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 |
|
||
| --- | --- | --- | --- |
|
||
| 39 | `GET` | `/api/v1/certifications` | `CertificationManager.jsx` ← `pages/Positions.jsx` *(unmounted)*, `pages/KrowIdentity.jsx` *(unmounted)* |
|
||
| 40 | `POST` | `/api/v1/certifications` | `CertificationManager.jsx` |
|
||
| 41 | `DELETE` | `/api/v1/certifications/{id}` | `CertificationManager.jsx` |
|
||
| 42 | `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, and it answers two different questions
|
||
depending on whether anything has been typed:
|
||
|
||
- **`query` present** — ranked against the static catalogue in `internal/owliver`.
|
||
No table is read, no transaction is opened and no model is called: the panel
|
||
issues one of these per keystroke, so the whole answer is a few string
|
||
comparisons.
|
||
- **`query` absent** — ranked against **the organization's actual state**, read
|
||
from PostgreSQL in one statement: how many positions are unfinished drafts,
|
||
how many active roles nobody has applied to, how many candidates are waiting
|
||
on a score, how many interviews carry a flag. This is one query per opened
|
||
panel, and it is what makes a suggestion react to the data — creating a
|
||
position changes what comes back next time it is asked.
|
||
|
||
Ranking here is by **tier first, count second**. Each reading declares how
|
||
much its subject matters when it is happening at all — a role nobody has
|
||
applied to outranks a queue of unscored candidates, which outranks a
|
||
headcount — and the count only orders readings inside a tier. Weight × count
|
||
would mean the largest pile always won, so a workspace with forty
|
||
applications and one abandoned role would be asked about the forty. A count
|
||
of zero scores nothing whatever its tier, so a page with nothing to report is
|
||
offered nothing rather than an urgent-sounding question about an empty set.
|
||
|
||
Only counts are read. Nothing that could name a record, a person or an id
|
||
reaches the ranking, and a talent caller's counts are never read at all: every
|
||
figure behind a highlight is organization-wide, and their rows are narrowed by
|
||
the policy table.
|
||
|
||
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 or blank `query` is
|
||
**not** an error — it is a request for what the data itself suggests. A `query`
|
||
that was typed but is too short to rank (under two letters or digits) answers
|
||
with `[]` rather than falling back to the data: the user is mid-word, and
|
||
replacing what they are typing towards would flicker.
|
||
|
||
### 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. At most **three**, always. There is no `meta`: the list is
|
||
capped rather than paged.
|
||
|
||
`capability` names the section type the answer should be drawn as, and is
|
||
present only where the query asked for one — so a highlight, which nobody typed,
|
||
never carries it. `intent` is the frontend capability id the panel dispatches
|
||
on; it is not invented server-side, and
|
||
`TestIntentIDsAreFrontendCapabilities` holds the two vocabularies together.
|
||
|
||
An empty array with no query typed means the organization has nothing worth
|
||
raising — a workspace with no positions is asked nothing rather than asked three
|
||
questions about empty sets.
|
||
|
||
| 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.
|