# Krow API Contract — v1 **Status: frozen for Phase 2C/2D implementation.** This document is derived entirely from the Krow frontend repository (`krow-demo`) as it stands, and from the Phase 1 schema in `migrations/000001_initial_schema.up.sql`. Nothing here is aspirational: every endpoint exists because a call site exists, and every semantic below was read out of `src/api/store.js` rather than designed. The acceptance criterion for Phase 2D is narrow and testable: > Replacing the transport inside `src/api/base44Client.js` — and changing no > other frontend file — must leave the application behaving identically. If implementing this contract requires editing `krowHooks.js`, a page or a component, the contract is wrong and gets fixed here first. --- ## 1. API conventions | Aspect | Decision | | --- | --- | | Base path | `/api/v1` | | Resource naming | kebab-case plural (`/job-postings`, `/worker-profiles`). Mass nouns stay singular: `/staff`, `/evidence`, `/user-activity`. | | Identifiers | `uuid` in the path. See §1.2 on legacy ids. | | Content type | `application/json; charset=utf-8` both ways | | Field naming | **snake_case, identical to the frontend's field names.** No renaming, no camelCase conversion. The one exception is `/me/preferences` — see §9. | | Dates | ISO 8601 with offset (`2026-08-21T10:04:53.402788Z`). `created_date` / `updated_date` keep those names. | | Partial updates | `PATCH`, shallow merge. There is no `PUT`. | | Reserved query params | `sort`, `limit`, `offset`. Every other query param is a field filter (§6). No current column collides with these three. | | Auth | **None in v1.** Every endpoint is unauthenticated. §9 documents the shape auth will take without implementing it. | ### 1.1 Why an envelope The frontend's entity methods return bare values today: `list()` and `filter()` return an array, `get()`/`create()`/`update()` return an object, `delete()` returns `{ id }`. The API nonetheless wraps responses in `{ "data": … }`. That is affordable precisely because `base44Client.js` is the one file allowed to change: the shim unwraps with a single `.data` and the hooks above it never see the envelope. What it buys is `meta.total`, which the bare shape has nowhere to put — and the collection caps (§8) silently truncate today, which is a known data-hiding bug the envelope makes fixable later without another contract change. ### 1.2 uuid vs. the frontend's string ids Phase 1 gives every table a `uuid` primary key plus a nullable unique `legacy_id text`. The frontend's existing ids (`jobposting_m1a2b3c001`, `user_demo`) are `legacy_id` values, not uuids. **The API accepts and returns `id` as the uuid.** Path lookups resolve a uuid. Records seeded from `src/api/seed.js` carry their original string in `legacy_id`, which is returned as a field but is never the addressable id. This is invisible to the frontend, which treats ids as opaque strings and never parses or constructs one — `makeId()` is called only inside `store.js`, which the transport swap replaces. **Verified:** no page, hook or component builds an id, pattern-matches one, or depends on its prefix. --- ## 2. Entity endpoints Derived from the operation matrix in §10.1. **An operation with no call site gets no endpoint.** `POST /job-postings/:id` does not exist because nothing in the frontend deletes a job posting. ### Live — reachable from a mounted route today | # | Method | Path | Purpose | | --- | --- | --- | --- | | 1 | `GET` | `/api/v1/job-postings` | List postings | | 2 | `GET` | `/api/v1/job-postings/{id}` | One posting | | 3 | `POST` | `/api/v1/job-postings` | Create a posting (incl. drafts) | | 4 | `PATCH` | `/api/v1/job-postings/{id}` | Update a posting | | 5 | `GET` | `/api/v1/job-applications` | List / filter applications | | 6 | `POST` | `/api/v1/job-applications` | Create an application | | 7 | `PATCH` | `/api/v1/job-applications/{id}` | Update — status, AI screening fields | | 8 | `DELETE` | `/api/v1/job-applications/{id}` | Remove an application | | 9 | `GET` | `/api/v1/ai-interviews` | List interviews | | 10 | `POST` | `/api/v1/ai-interviews` | Record an interview result | | 11 | `GET` | `/api/v1/staff` | List hires | | 12 | `POST` | `/api/v1/staff` | Create a hire record | | 13 | `PATCH` | `/api/v1/staff/{id}` | Update a hire (rating, endorsement) | | 14 | `GET` | `/api/v1/worker-profiles` | List / filter profiles | | 15 | `POST` | `/api/v1/worker-profiles` | Create a profile | | 16 | `PATCH` | `/api/v1/worker-profiles/{id}` | Update a profile | | 17 | `GET` | `/api/v1/courses` | List courses | | 18 | `GET` | `/api/v1/courses/{id}` | One course | | 19 | `POST` | `/api/v1/courses` | Create a course | | 20 | `PATCH` | `/api/v1/courses/{id}` | Update a course | | 21 | `GET` | `/api/v1/learning-paths` | List learning paths | | 22 | `GET` | `/api/v1/role-categories` | List role categories | | 23 | `POST` | `/api/v1/role-categories` | Create a role category | | 24 | `GET` | `/api/v1/user-activity` | List / filter the activity log | | 25 | `POST` | `/api/v1/user-activity` | Append an activity event | | 26 | `GET` | `/api/v1/assignments` | List assignments | | 27 | `POST` | `/api/v1/assignments` | Create an assignment | | 28 | `GET` | `/api/v1/shift-records` | List shift records | | 29 | `POST` | `/api/v1/evidence` | Submit challenge evidence | | 30 | `PATCH` | `/api/v1/evidence/{id}` | Supervisor-verify evidence | | 31 | `GET` | `/api/v1/me` | Current user | | 32 | `PATCH` | `/api/v1/me` | Update current user | | 33 | `GET` | `/api/v1/me/preferences` | Read preferences | | 34 | `PATCH` | `/api/v1/me/preferences` | Merge preferences | ### Unreachable today — included deliberately (D6) The calling code exists and compiles; only its route is unmounted. Excluding these would leave the shim with methods that 404. See §11 (D6). | # | Method | Path | Sole consumer | | --- | --- | --- | --- | | 35 | `GET` | `/api/v1/certifications` | `CertificationManager.jsx` ← `pages/Positions.jsx` *(unmounted)*, `pages/KrowIdentity.jsx` *(unmounted)* | | 36 | `POST` | `/api/v1/certifications` | `CertificationManager.jsx` | | 37 | `DELETE` | `/api/v1/certifications/{id}` | `CertificationManager.jsx` | | 38 | `GET` | `/api/v1/evidence` | `useEvidenceList` — **zero consumers**; included only so the shim's `Evidence.list/filter` resolves | ### Not in v1 | Entity/op | Why | | --- | --- | | `GET /badges` | `useBadges` has **zero consumers**. Every badge the UI renders comes from `worker_profiles.earned_badges`. The table exists; nothing reads it. | | `DELETE` on any resource but `job-applications` and `certifications` | No call site. | | `POST /shift-records` | Nothing in the frontend creates one. See §11 (U1). | | `GET /assignments/{id}`, `PATCH /assignments/{id}` | No call site — assignments are only listed and created. | | `POST /ai-interviews/{id}` updates | No call site. | | Multi-record transaction endpoints (hire, assign, screen-all, submit-challenge) | Deferred to Phase 3. See §12.1. | --- ## 2A. Owliver suggestions `GET /api/v1/owliver/suggestions?page={surface}&query={typed}` The one endpoint here that serves no resource. It answers "what could I usefully ask on this page?" for the Owliver panel, which calls it while the user types — so it reads no table, opens no transaction, calls no model, and its whole answer is computed from a static catalogue in `internal/owliver`. It is not on the public allowlist. Which readings exist depends on the caller's role, so there is no anonymous answer to give. ### Request | Parameter | Required | Meaning | | --- | --- | --- | | `page` | yes | A surface id from the closed page vocabulary — the same one `internal/definition` validates a definition's `pages:` against. Aliases (`hired`, `forge`, `new-position`) and loose spellings (`Talent Pool`) resolve to the canonical id. | | `query` | no | What the user has typed so far. | Any other parameter is `invalid_query`. **Nothing about the caller is accepted here** — role, organization and user are read from the session, and a request that names one is refused rather than ignored. An unknown `page` is `invalid_query`, with the frontend's own wording: `Unsupported page: {value}. Supported pages: {…}.` An absent, blank or too-short `query` is **not** an error — there is simply nothing to rank yet. ### Response ```json { "data": { "suggestions": [ { "text": "Which position has the strongest pipeline?", "intent": "position-strength" }, { "text": "Show hiring activity as a flow", "intent": "hiring-operations", "capability": "flow" } ] } } ``` `data.suggestions` is always an array — `[]` when nothing matches, never `null` and never an error. There is no `meta`: the list is capped rather than paged. | Field | Meaning | | --- | --- | | `text` | The question, as the user reads it. | | `intent` | The **frontend capability id** the panel dispatches on, verbatim from the manifests in `src/components/ai-assistant/capabilities/`. Not a backend identifier, and never invented here. | | `capability` | The section type to draw the answer as, from the closed `OWLIVER_CAPABILITIES` vocabulary. Present only when the query asked for one ("as a flow", "summarize"); absent otherwise. | Nothing internal is exposed: no matching terms, no resource names, no scores, no policy detail. `text` is always catalogue wording — no part of the query is echoed back into a suggestion. ### Selection Five stages, each of which only ever removes: ``` the page's catalogue → permission → relevance → deduplicate → top 3 ``` - **Page.** Intents are keyed by surface, so a suggestion from another page cannot appear. The same word answers differently per page by construction: `pipeline` on `positions` is about which role converts, on `candidates` it is the funnel the applicants are in. - **Permission.** Each intent declares the resources it reads, and those are checked against the policy table in §9A — *before* ranking, so a refused reading is never scored. Two gates, not one: the role must be allowed the operation, and for an organization-wide reading the role's rows must not be narrowed. Talent may list job applications; talent may not be offered "which position has the strongest pipeline?", because their view of that resource is their own rows. No role list is written down here — see §9A.1. - **Relevance.** Deterministic keyword ranking over the intent's own terms. Exact token, then multi-word phrase, then prefix (so a half-typed word still matches), then extension. Ties break on catalogue order, so the same request always answers identically. A query naming only a section type ranks the page's readings; once it names a subject, readings that merely *support* that shape are dropped rather than used as padding. - **Deduplicate.** One suggestion per intent id, and no two with the same text. - **Cap.** Three. Nothing is added to reach three. A query with fewer than two letters or digits after normalization returns `[]`. ### Normalization The query is truncated to 200 characters, lower-cased, and reduced to letters, digits and single spaces — every other character becomes a space rather than being stripped, so nothing can be glued into a token that was not typed as one. There is no injection surface to defend: the normalized text is compared against a fixed table of literals and never reaches SQL, a template, a shell or a log message. Hostile input is ranked like any other text, and can only ever produce entries the catalogue already holds. ### Where the catalogue comes from The frontend owns the vocabulary. Owliver's capabilities are declared per page context in `src/components/ai-assistant/capabilities/`; `internal/owliver` transcribes the id and the page, and adds the two things a manifest does not carry — the words that mean a user is reaching for that reading, and the records it reads. Same pattern as `internal/definition/vocabulary.go`, and for the same reason: one vocabulary, named from both ends, with a test on this side that fails when an id here names no capability there. **No table, no migration.** The suggestions are derived from definitions that already exist. Persistence would only be warranted if suggestions became admin-managed, and nothing in the product asks for that today. ### Not covered The seven workspace and configuration surfaces — `settings`, `workspace`, `workspace-agents`, `workspace-skills`, `workspace-skill-configure`, `skill-development`, `workspace-agent-configure` — are valid pages with no entries. Their panel answers from the registries rather than from workforce records, so there is nothing to rank a typed query against. They return `[]`, which is the honest answer, not a validation error. --- ## 3. Request schemas ### 3.1 Create — `POST /{resource}` Body is a **flat object of column values**, exactly the object the frontend passes to `create()` today. No envelope on the request. The server supplies `id`, `created_date`, `updated_date` and `org_id`. Any of those in the body is **ignored, not rejected** — see §7.4 for why that distinction is load-bearing. Every column not supplied takes its schema default. Because the Phase 1 schema declares `NOT NULL DEFAULT ''` / `'{}'` / `'[]'` on effectively every optional column, a create with only the required fields succeeds and returns a fully populated record — which is what `store.js` does today by returning whatever the caller spread in. Required per resource (everything else optional): | Resource | Required | | --- | --- | | `job-postings` | `title` (non-blank) | | `job-applications` | `job_posting_id`, `applicant_name` (non-blank), `email` | | `ai-interviews` | `application_id`, `job_posting_id` | | `staff` | `name` (non-blank), `email`, `hire_date` | | `worker-profiles` | `full_name` (non-blank), `email` | | `courses` | `title` (non-blank) | | `role-categories` | `name` | | `certifications` | `name` | | `user-activity` | `event_type` (non-blank) | | `assignments` | `job_posting_id`, `worker_email`, `starts_at` | | `evidence` | `type`, `worker_email` | ### 3.2 Update — `PATCH /{resource}/{id}` Body is a **partial** object. Only the keys present are written; every other column is left alone. This mirrors `store.js`'s `{ ...existing, ...data, updated_date: now }` exactly. - A key present with `null` sets the column to `NULL` (and is rejected if the column is `NOT NULL`). - A key absent is not touched. - `id`, `created_date` and `org_id` in the body are ignored. - `updated_date` is always set server-side to `now()`, overriding any supplied value — `store.js` does the same by placing it after the spread. **There is no deep merge.** `store.js` shallow-merges, so `PATCH` with `{"vetting_criteria": {"experience": 30}}` **replaces** the whole object rather than merging into it. Preserving this matters: `useUpdateWorkerProfile` sends whole recomputed arrays (`completed_courses`, `earned_badges`, `capabilities`), and a deep merge would append instead of replace. ### 3.3 Delete — `DELETE /{resource}/{id}` No body. --- ## 4. Response schemas ### 4.1 Single record ```json { "data": { "id": "…", "title": "Bartender", "created_date": "…", … } } ``` Returned by `GET /{r}/{id}`, `POST /{r}`, `PATCH /{r}/{id}`. The record is **complete** — every column, including defaults the client never sent. `store.js` returns a structured clone of the stored record, so the client already relies on getting the whole thing back (`useHireCandidate` reads `updated.status`; `useAssignWorkers` mutates `record.application_id` on the returned assignment). ### 4.2 Collection ```json { "data": [ { … }, { … } ], "meta": { "total": 214, "limit": 100, "offset": 0, "returned": 100, "truncated": true } } ``` `truncated` is `true` when `total > offset + returned`. Nothing reads it yet; it exists so the silent-truncation bug (§12.2) is fixable without a contract change. ### 4.3 Delete ```json { "data": { "id": "…" } } ``` Matching `store.js`'s `return { id }`. --- ## 5. Error format ```json { "error": { "code": "not_found", "message": "JobPosting 7c9e… not found", "details": {} } } ``` | Code | HTTP | When | | --- | --- | --- | | `unauthorized` | 401 | No valid session cookie, or a failed sign-in. See §9. | | `forbidden` | 403 | Authenticated, but the caller's role does not permit the operation. See §9A.2. | | `rate_limited` | 429 | Too many failed sign-in attempts. Carries `Retry-After`. | | `not_found` | 404 | `GET`/`PATCH` on a missing id — including a row outside the caller's organization or, for talent, not their own | | `validation_failed` | 422 | Body violates a column constraint. `details` maps field → reason. | | `invalid_query` | 400 | Unknown filter field, malformed `sort`, non-integer `limit` | | `conflict` | 409 | Unique violation (e.g. a second application for the same `(job_posting_id, email)`) | | `internal` | 500 | Anything else. `message` is generic; the detail goes to the log with a request id. | ### 5.1 The `message` field is load-bearing `store.js` throws `new Error(\`${name} ${id} not found\`)`, and the shim must reproduce a thrown `Error` for a missing record because React Query's `isError` path and several `.catch(() => …)` fallbacks depend on it. The shim converts any non-2xx into `throw new Error(body.error.message)`. **Consequence for `not_found`:** the message must read `" not found"` using the frontend's PascalCase entity name, not the table name. Nothing parses it today, but it appears in console output and in `PageNotFound`'s diagnostics, so keeping it identical costs nothing. --- ## 6. Filtering semantics `store.js`: ```js function matches(record, query) { return Object.entries(query).every(([key, want]) => { const got = record?.[key]; if (Array.isArray(want)) return want.includes(got); return got === want; }); } ``` That is the entire filter language. It is reproduced exactly: | Behaviour | Rule | | --- | --- | | Combination | **AND** across every field. `.every()`. | | Scalar | Strict equality. `WHERE col = $1` | | Array | Membership. `WHERE col = ANY($1)`. Expressed as a repeated query param: `?status=applied&status=hired` | | Operators | **None.** No `gt`, `lt`, `like`, `contains`, `between`. Adding one is a contract change. | | Unknown field | `400 invalid_query`. `store.js` would return zero rows (every record's `undefined !== want`); a 400 is strictly better and cannot break a caller, because no caller sends an unknown field. | | Array-valued columns | Not filterable. `got === want` is a reference comparison against a JS array and is always false, so `filter({ skills: 'Bartending' })` returns nothing today. The API must **not** silently improve this into a containment query. | | `null` | Not expressible. No caller filters on null. | | Empty query | Equivalent to `list()`. | | Type coercion | Query params arrive as strings; the server coerces per column type before comparing. **Every filter in use today is on a `text`/`citext` column**, so this is currently a no-op — but `?ai_score=90` must compare as an integer, not a string. | ### 6.1 Filters actually in use Only four call sites filter. Any other combination is untested and unsupported in v1. | Endpoint | Filter | Call site | | --- | --- | --- | | `GET /job-applications` | `job_posting_id` | `krowHooks.js:85` | | `GET /worker-profiles` | `email` | `krowHooks.js:554` | | `GET /evidence` | `worker_email` | `krowHooks.js:633` *(hook has no consumers)* | | `GET /user-activity` | `user_email` | `pages/Profile.jsx:28` *(unmounted — D6)* | `email` and `worker_email` map to `citext` columns, so matching is case-insensitive **server-side** where `store.js` was case-sensitive. This is a deliberate, safe divergence: `useAssignWorkers` already lowercases both sides before comparing (`krowHooks.js:445`), so the frontend's own intent is case-insensitive, and `worker_profiles` has a `UNIQUE (org_id, email)` that makes a case-variant duplicate impossible to create. --- ## 7. Sorting semantics ```js function applySort(records, sort) { if (!sort) return records; const desc = sort.startsWith('-'); const field = desc ? sort.slice(1) : sort; return [...records].sort((a, b) => { const av = a?.[field]; const bv = b?.[field]; if (av === bv) return 0; if (av === undefined || av === null) return 1; if (bv === undefined || bv === null) return -1; const result = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv)); return desc ? -result : result; }); } ``` | Rule | Behaviour | | --- | --- | | Syntax | `?sort=-created_date` — a leading `-` means descending, otherwise ascending. One field only. | | Default | `-created_date` on every collection. Applied when `sort` is absent. | | Empty string | `?sort=` returns rows in insertion order, unsorted. Preserved as "no `ORDER BY`". | | Unknown field | `400 invalid_query`. | ### 7.1 NULLs sort last in **both** directions The two null branches `return` before `desc` is applied, so a null is greater than everything ascending *and* descending. This is not a bug to fix — it is observable behaviour that the SQL must reproduce: ```sql ORDER BY col DESC NULLS LAST -- descending ORDER BY col ASC NULLS LAST -- ascending, note: NOT the SQL default ``` PostgreSQL's default is `NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`, so the descending case **must** be written explicitly or nulls will surface at the top of every list where `store.js` put them at the bottom. ### 7.2 Non-numeric comparison is lexicographic When either value is not a number, `store.js` compares `String(av).localeCompare(String(bv))`. For the sorts actually in use this is equivalent to a SQL text sort: - `-created_date` — ISO 8601 strings sort lexicographically the same as chronologically. Sorting the `timestamptz` column directly is correct. - `-ai_score`, `-krow_score` — both numeric on both sides, so `av - bv`. Sorting the `int` column is correct. No other sort field is used, so no collation edge case is reachable in v1. ### 7.3 Sort stability `Array.prototype.sort` is stable in every engine the app targets, so equal keys keep insertion order. PostgreSQL guarantees no such thing. **A tiebreaker is required:** every `ORDER BY` appends `, id` so repeated identical requests return the same order. Without it, paginated lists can drop or duplicate rows between pages. ### 7.4 Insertion order is newest-first `create()` does `table().unshift(record)` — new records go to the **front**. With the default `-created_date` sort this is already the outcome, so it is only observable when two records share a timestamp or when `sort` is empty. The `, id` tiebreaker does not reproduce it exactly; the divergence is limited to same-millisecond inserts and nothing depends on it. **Related:** `create()` spreads `...data` *after* the generated `id` and `created_date`, so a caller-supplied `id` or `created_date` **wins** today. No caller does this, which is why §3.1 ignores those fields rather than honouring them — but a seeder that needs to preserve ids must write them directly, not through `POST`. --- ## 8. Pagination semantics **The frontend does not paginate over the network.** This is the single most important thing not to redesign. Every collection is fetched once, whole, up to a hard cap, and every page then filters, sorts, searches and paginates **in the browser** over that array. `components/ds/Pagination.jsx` is a pure client control; `DataTable` slices `sortedRows` locally. `DataTable` does accept a `serverPaginated` prop — **no page passes it.** | Rule | Behaviour | | --- | --- | | `?limit=` | Max rows returned. Defaults to the per-endpoint value in §8.1. | | `?offset=` | Rows to skip. Defaults to `0`. **No current caller sends it.** | | Cap | `limit` is clamped to `1000`. Nothing requests more than 500. | | Empty result | `{"data": [], "meta": {"total": 0, …}}` and **HTTP 200**. Never 404. `store.js` returns `[]`, and every consumer defaults with `= []`. | ### 8.1 Per-endpoint default limits — these are not arbitrary Each default is the exact second argument at the call site. Changing one changes what the UI shows, because the truncation happens before any client-side filter runs. | Endpoint | Default `limit` | Default `sort` | Call site | | --- | --- | --- | --- | | `/job-postings` | 100 | `-created_date` | `krowHooks.js:68` | | `/job-applications` | **200** | **`-ai_score`** | `krowHooks.js:85-86` | | `/ai-interviews` | 100 | `-created_date` | `krowHooks.js:93` | | `/shift-records` | **500** | `-created_date` | `krowHooks.js:108` | | `/staff` | 100 | `-created_date` | `krowHooks.js:115` | | `/role-categories` | 100 | `-created_date` | `krowHooks.js:132` | | `/certifications` | 200 | `-created_date` | `krowHooks.js:139` | | `/user-activity` | **500** | `-created_date` | `krowHooks.js:176` | | `/user-activity` (filtered) | **20** | `-created_date` | `pages/Profile.jsx:28` | | `/courses` | 200 | `-created_date` | `krowHooks.js:346` | | `/assignments` | 500 | `-created_date` | `krowHooks.js:391` | | `/learning-paths` | 100 | `-created_date` | `krowHooks.js:534` | | `/badges` | 200 | `-created_date` | `krowHooks.js:538` *(no consumer)* | | `/worker-profiles` | 500 | **`-krow_score`** | `krowHooks.js:583` | | `/worker-profiles` (filtered) | **1** | `-created_date` | `krowHooks.js:554` | | `/evidence` | 200 | `-created_date` | `krowHooks.js:633-634` | The shim sends these explicitly rather than relying on server defaults, so the numbers stay visible at the call site where they already live. ### 8.2 Latency `store.js` injects 140 ms on reads and 220 ms on writes so loading states are real. Real network latency replaces it. The loading states are already designed and exercised — but they have only ever seen *uniform* latency, so Phase 2D should test slow and failing responses, not just successful ones. --- ## 9. Current-user / auth contract **Authentication is required.** Every endpoint in this document except `GET /health`, `POST /api/v1/auth/login` and `POST /api/v1/auth/logout` answers `401` with `{"error":{"code":"unauthorized","message":"authentication required"}}` unless the request carries a valid session cookie. `base44.auth` surface in use: `me()` ×11, `logout()` ×5, `updateMe()` ×3, `preferences()` ×2, `updatePreferences()` ×1, `redirectToLogin()` ×1, plus `login()`. ### `POST /api/v1/auth/login` Public. Body: `{"email": "…", "password": "…", "remember_me": false}`. `200` returns the user, in the same shape as `GET /me`, and sets a session cookie: `krow_session=; Path=/; HttpOnly; SameSite=Lax`, plus `Secure` when `APP_ENV != development`, with `Max-Age` matching the session lifetime — 12 hours normally, 30 days with `remember_me`. **The token is never in the response body.** It exists in the `Set-Cookie` header and in the browser's cookie store; PostgreSQL holds only its SHA-256. `422` when `email` or `password` is missing. `429` when too many failed attempts have been made against this email or from this address. Every credential failure — wrong password, unknown email, no password set, suspended account — is the same `401` with the same body, so the endpoint cannot be used to discover which addresses are registered. ### `POST /api/v1/auth/logout` Public and idempotent. Revokes the session behind the cookie if there is one, expires the cookie, and answers `200` with `{"data":{"status":"signed_out"}}` — including when the cookie is absent, stale or was never valid. ### `GET /api/v1/me` Returns the user behind the session cookie. Response `data`: ```json { "id": "…uuid…", "legacy_id": "user_demo", "full_name": "Alex Rivera", "email": "demo@krow.app", "role": "admin", "account_type": "employer", "created_date": "2026-06-01T00:00:00.000Z", "preferences": { "owliverDefault": true, "compactDensity": false, "emailDigest": true } } ``` `preferences` is **embedded in the user object**, because `krowHooks.js:42` reads `user?.preferences` directly. ### `PATCH /api/v1/me` Shallow merge, returns the full updated user. Used by `layouts/Layout.jsx:73` (`account_type` role switch, unmounted) and `pages/admin/Profile.jsx`. ### `GET` / `PATCH /api/v1/me/preferences` **The one place field naming diverges from the database.** The frontend uses camelCase keys; Phase 1 stores three of them as snake_case columns plus an `extra` jsonb blob: | API key (camelCase) | Column | | --- | --- | | `owliverDefault` | `user_preferences.owliver_default` | | `compactDensity` | `user_preferences.compact_density` | | `emailDigest` | `user_preferences.email_digest` | | everything else | merged into `user_preferences.extra` (jsonb) | "Everything else" is not a hypothetical: `customSkills` and `customAgents` — every account-authored skill and agent definition — live in this blob today, written by `WorkspaceSkills.jsx`, `SkillEditor.jsx`, `OwliverSkillEditor.jsx` and `useAgents.js`. They are opaque to the API in v1 and are promoted to real tables in a later phase. `PATCH` **shallow-merges** the supplied keys into the existing preferences and returns the merged object. ### The `{ persisted }` return shape `auth.updatePreferences()` returns `{ user, persisted, error }`, not a bare user. This exists because a swallowed `QuotaExceededError` silently lost account-authored skills — see the comment at `base44Client.js:persistUser`. Over HTTP a failed write is already a non-2xx, so the shim synthesises `{ user: data, persisted: true, error: null }` on success and lets the throw path handle failure. `lib/skills/saveFeedback.js` is the sole consumer and needs no change. ### `logout()` / `redirectToLogin()` Client-side only in v1 — clear local session state and navigate. **No endpoint.** Nothing is called over the network. --- ## 9A. Authorization contract Authentication answers *who is calling*; this section answers *what they may do*. Both are implemented. Every rule below is enforced in the API and covered by a test — nothing here is aspirational. ### 9A.1 The authority `users.role`, and only `users.role`. Three values, fixed by the `users_role_check` constraint in migration 000001: | Role | Who | |---|---| | `admin` | runs the platform for the organization | | `employer` | runs the organization's hiring and workforce | | `talent` | a worker, acting for themselves | **`account_type` is not an authorization field.** It is a display attribute the user may change on themselves through `PATCH /me`, and nothing in the API reads it to make a decision. Neither is anything in a request body, a header, or the browser: the role is read from the `users` row named by the session. An unrecognised role authorizes nothing. ### 9A.2 401, 403 and 404 Three refusals, and the difference between them is load-bearing: | Status | Code | Meaning | |---|---|---| | `401` | `unauthorized` | No valid session. The caller is nobody. | | `403` | `forbidden` | The caller is known and their **role** does not permit the operation. | | `404` | `not_found` | The **row** is outside the caller's organization or, for talent, is not theirs. | The role check runs in the handler before any query, so it is always `403` and never reveals whether a row exists. Organization and ownership are SQL predicates, so a row outside them is simply absent — a caller cannot tell "it exists and is not yours" from "it does not exist". The `403` message names neither the caller's role nor the roles that would have worked. `DELETE` continues to answer `200` whether or not a row matched (§12.7), which discloses nothing either way. ### 9A.3 Permission matrix Read `own` as "restricted to their own rows by a SQL predicate" — see §9A.4. | Resource | Admin | Employer | Talent | |---|---|---|---| | `/me`, `/me/preferences` | R U | R U | R U | | `job-postings` | R C U | R C U | **R** *(active only)* | | `job-applications` | R C U D | R C U D | **R C** *(own)* | | `ai-interviews` | R C | R C | **R C** *(own)* | | `staff` | R C U | R C U | — | | `worker-profiles` | R C U | R C U | **R C U** *(own)* | | `assignments` | R C | R C | **R** *(own)* | | `shift-records` | R | R | **R** *(own)* | | `courses` | R C U | R | R | | `learning-paths` | R | R | R | | `role-categories` | R C | R C | R | | `certifications` | R C D | R C | R | | `user-activity` | R C | R C | **R** *(own)* C | | `evidence` | R C U | R C U | **R C** *(own)* | | `badges` | — | — | — | Two admin-only operations, and both have a reason beyond seniority: - **`POST`/`PATCH /courses`** — a course with a NULL `org_id` is the shared platform library, visible to every tenant, so a write here can reach beyond the writer's own organization. - **`DELETE /certifications/{id}`** — deleting one changes what every existing posting that required it means. `badges` has no endpoints at all (`Ops: 0`); its empty policy is written down so the resource is deliberately closed rather than merely forgotten. ### 9A.4 Ownership For a talent caller, reads and writes carry a second predicate beside the organization scope, in the same `WHERE` clause: | Resource | Predicate | |---|---| | `worker-profiles` | `user_id = ` | | `job-applications` | `email = ` | | `assignments` | `worker_email = ` | | `shift-records` | `worker_email = ` | | `evidence` | `worker_email = ` | | `user-activity` | `user_email = ` | | `ai-interviews` | `application_id IN (SELECT id FROM job_applications WHERE org_id = … AND 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..(` 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.