50 KiB
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
nullsets the column toNULL(and is rejected if the column isNOT NULL). - A key absent is not touched.
id,created_dateandorg_idin the body are ignored.updated_dateis always set server-side tonow(), overriding any supplied value —store.jsdoes 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
{ "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
{
"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
{ "data": { "id": "…" } }
Matching store.js's return { id }.
5. Error format
{
"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 Errorfor a missing record because React Query'sisErrorpath and several.catch(() => …)fallbacks depend on it. The shim converts any non-2xx intothrow 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:
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
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:
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 thetimestamptzcolumn directly is correct.-ai_score,-krow_score— both numeric on both sides, soav - bv. Sorting theintcolumn 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:
{
"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 NULLorg_idis 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:
- Email matching is case-insensitive.
citextcolumns compare without regard to case wherestore.jsused===. Safe:useAssignWorkersalready lowercases both sides (krowHooks.js:445) andUNIQUE (org_id, email)makes a case-variant duplicate impossible. §6.1. updated_dateis always populated. Only job applications carry it in the source; every other entity has none, andstore.jstherefore returnsundefined. The column isNOT NULL, so the seeder sets it tocreated_date— the record has not been modified since creation. One visible effect:TalentDetailModal.jsx:66readsprofile.updated_date || new Date()and will now show the seeded date rather than today._orderis dropped. A positional indexseed.js:1326uses 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.