Files
krow_backend/docs/api-contract.md
2026-08-24 13:06:29 +05:30

50 KiB
Raw Permalink Blame History

Krow API Contract — v1

Status: frozen for Phase 2C/2D implementation. This document is derived entirely from the Krow frontend repository (krow-demo) as it stands, and from the Phase 1 schema in migrations/000001_initial_schema.up.sql. Nothing here is aspirational: every endpoint exists because a call site exists, and every semantic below was read out of src/api/store.js rather than designed.

The acceptance criterion for Phase 2D is narrow and testable:

Replacing the transport inside src/api/base44Client.js — and changing no other frontend file — must leave the application behaving identically.

If implementing this contract requires editing krowHooks.js, a page or a component, the contract is wrong and gets fixed here first.


1. API conventions

Aspect Decision
Base path /api/v1
Resource naming kebab-case plural (/job-postings, /worker-profiles). Mass nouns stay singular: /staff, /evidence, /user-activity.
Identifiers uuid in the path. See §1.2 on legacy ids.
Content type application/json; charset=utf-8 both ways
Field naming snake_case, identical to the frontend's field names. No renaming, no camelCase conversion. The one exception is /me/preferences — see §9.
Dates ISO 8601 with offset (2026-08-21T10:04:53.402788Z). created_date / updated_date keep those names.
Partial updates PATCH, shallow merge. There is no PUT.
Reserved query params sort, limit, offset. Every other query param is a field filter (§6). No current column collides with these three.
Auth None in v1. Every endpoint is unauthenticated. §9 documents the shape auth will take without implementing it.

1.1 Why an envelope

The frontend's entity methods return bare values today: list() and filter() return an array, get()/create()/update() return an object, delete() returns { id }. The API nonetheless wraps responses in { "data": … }.

That is affordable precisely because base44Client.js is the one file allowed to change: the shim unwraps with a single .data and the hooks above it never see the envelope. What it buys is meta.total, which the bare shape has nowhere to put — and the collection caps (§8) silently truncate today, which is a known data-hiding bug the envelope makes fixable later without another contract change.

1.2 uuid vs. the frontend's string ids

Phase 1 gives every table a uuid primary key plus a nullable unique legacy_id text. The frontend's existing ids (jobposting_m1a2b3c001, user_demo) are legacy_id values, not uuids.

The API accepts and returns id as the uuid. Path lookups resolve a uuid. Records seeded from src/api/seed.js carry their original string in legacy_id, which is returned as a field but is never the addressable id.

This is invisible to the frontend, which treats ids as opaque strings and never parses or constructs one — makeId() is called only inside store.js, which the transport swap replaces. Verified: no page, hook or component builds an id, pattern-matches one, or depends on its prefix.


2. Entity endpoints

Derived from the operation matrix in §10.1. An operation with no call site gets no endpoint. POST /job-postings/:id does not exist because nothing in the frontend deletes a job posting.

Live — reachable from a mounted route today

# Method Path Purpose
1 GET /api/v1/job-postings List postings
2 GET /api/v1/job-postings/{id} One posting
3 POST /api/v1/job-postings Create a posting (incl. drafts)
4 PATCH /api/v1/job-postings/{id} Update a posting
5 GET /api/v1/job-applications List / filter applications
6 POST /api/v1/job-applications Create an application
7 PATCH /api/v1/job-applications/{id} Update — status, AI screening fields
8 DELETE /api/v1/job-applications/{id} Remove an application
9 GET /api/v1/ai-interviews List interviews
10 POST /api/v1/ai-interviews Record an interview result
11 GET /api/v1/staff List hires
12 POST /api/v1/staff Create a hire record
13 PATCH /api/v1/staff/{id} Update a hire (rating, endorsement)
14 GET /api/v1/worker-profiles List / filter profiles
15 POST /api/v1/worker-profiles Create a profile
16 PATCH /api/v1/worker-profiles/{id} Update a profile
17 GET /api/v1/courses List courses
18 GET /api/v1/courses/{id} One course
19 POST /api/v1/courses Create a course
20 PATCH /api/v1/courses/{id} Update a course
21 GET /api/v1/learning-paths List learning paths
22 GET /api/v1/role-categories List role categories
23 POST /api/v1/role-categories Create a role category
24 GET /api/v1/user-activity List / filter the activity log
25 POST /api/v1/user-activity Append an activity event
26 GET /api/v1/assignments List assignments
27 POST /api/v1/assignments Create an assignment
28 GET /api/v1/shift-records List shift records
29 POST /api/v1/evidence Submit challenge evidence
30 PATCH /api/v1/evidence/{id} Supervisor-verify evidence
31 GET /api/v1/me Current user
32 PATCH /api/v1/me Update current user
33 GET /api/v1/me/preferences Read preferences
34 PATCH /api/v1/me/preferences Merge preferences

Unreachable today — included deliberately (D6)

The calling code exists and compiles; only its route is unmounted. Excluding these would leave the shim with methods that 404. See §11 (D6).

# Method Path Sole consumer
35 GET /api/v1/certifications CertificationManager.jsx ← pages/Positions.jsx (unmounted), pages/KrowIdentity.jsx (unmounted)
36 POST /api/v1/certifications CertificationManager.jsx
37 DELETE /api/v1/certifications/{id} CertificationManager.jsx
38 GET /api/v1/evidence useEvidenceList — zero consumers; included only so the shim's Evidence.list/filter resolves

Not in v1

Entity/op Why
GET /badges useBadges has zero consumers. Every badge the UI renders comes from worker_profiles.earned_badges. The table exists; nothing reads it.
DELETE on any resource but job-applications and certifications No call site.
POST /shift-records Nothing in the frontend creates one. See §11 (U1).
GET /assignments/{id}, PATCH /assignments/{id} No call site — assignments are only listed and created.
POST /ai-interviews/{id} updates No call site.
Multi-record transaction endpoints (hire, assign, screen-all, submit-challenge) Deferred to Phase 3. See §12.1.

3. Request schemas

3.1 Create — POST /{resource}

Body is a flat object of column values, exactly the object the frontend passes to create() today. No envelope on the request.

The server supplies id, created_date, updated_date and org_id. Any of those in the body is ignored, not rejected — see §7.4 for why that distinction is load-bearing.

Every column not supplied takes its schema default. Because the Phase 1 schema declares NOT NULL DEFAULT '' / '{}' / '[]' on effectively every optional column, a create with only the required fields succeeds and returns a fully populated record — which is what store.js does today by returning whatever the caller spread in.

Required per resource (everything else optional):

Resource Required
job-postings title (non-blank)
job-applications job_posting_id, applicant_name (non-blank), email
ai-interviews application_id, job_posting_id
staff name (non-blank), email, hire_date
worker-profiles full_name (non-blank), email
courses title (non-blank)
role-categories name
certifications name
user-activity event_type (non-blank)
assignments job_posting_id, worker_email, starts_at
evidence type, worker_email

3.2 Update — PATCH /{resource}/{id}

Body is a partial object. Only the keys present are written; every other column is left alone. This mirrors store.js's { ...existing, ...data, updated_date: now } exactly.

  • A key present with null sets the column to NULL (and is rejected if the column is NOT NULL).
  • A key absent is not touched.
  • id, created_date and org_id in the body are ignored.
  • updated_date is always set server-side to now(), overriding any supplied value — store.js does the same by placing it after the spread.

There is no deep merge. store.js shallow-merges, so PATCH with {"vetting_criteria": {"experience": 30}} replaces the whole object rather than merging into it. Preserving this matters: useUpdateWorkerProfile sends whole recomputed arrays (completed_courses, earned_badges, capabilities), and a deep merge would append instead of replace.

3.3 Delete — DELETE /{resource}/{id}

No body.


4. Response schemas

4.1 Single record

{ "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 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:

{
  "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.