# Krow backend — implementation plan **Status:** blueprint. No code has been changed to produce it. **Backend audited:** `/Users/apple/Krow/krow-backend` @ `cadea4b` (branch `main`). **Frontend audited:** `/Users/apple/Krow/krow-demo`, normative inventory at `krow-demo/docs/api-audit.md`. --- ## 0. How to read this document Every row is traced. A claim about the server names a Go file and, where it matters, a line; a claim about the browser names a file in `krow-demo/src`; a claim about the schema names a migration. Nothing here is inferred from a README — where a README and the code disagree, the code won, and the disagreement is recorded in §13. Four labels are used throughout, and they are the whole of the taxonomy: | Label | Meaning | | --- | --- | | **EXISTS** | Registered and working on the server, and the frontend calls it. Nothing to do. | | **WIRE** | Registered and working on the server. The frontend does not call it. **Frontend-only work; zero server changes.** | | **CHANGE** | The endpoint exists but its behaviour is wrong, incomplete, or disagrees with the frontend. **Server change to existing code.** | | **NEW** | No server capability exists at all. Proposed only where §10 justifies it. | Two rules were held to while writing this: - **No route is invented.** Every recommendation either reuses a registered route, changes the behaviour behind one, or is listed in §10 with an explicit justification, request/response shape, service, repository, DB and migration impact. - **No duplicate API is proposed for functionality that already has one.** Where a need could be met either by a new route or by changing an existing handler, the plan takes the existing handler. §5.2 (interview completion) is the clearest case: it is solved by extending `POST /api/v1/ai-interviews`, not by adding an endpoint. --- ## 1. Every registered backend endpoint ### 1.1 The exact route count, derived from source `Server.New` registers `GET /health` first and then sums five helpers (`go-api/internal/httpserver/server.go` (`Server.New`, lines 146-148)): ```go mux.HandleFunc("GET /health", s.handleHealth) s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) + s.routeDefinitions(mux) + s.routeWorkflows(mux) ``` | Group | Routes | Counted from | | --- | ---: | --- | | Health | 1 | `internal/httpserver/server.go:146` | | Auth | 2 | `internal/httpserver/auth.go:129-130` (`return 2`) | | Entity CRUD | 34 | `Ops` bits over 14 resources, `internal/domain/resources_gen.go` | | `/me` | 4 | `internal/httpserver/me.go:83-86` (`return 4`) | | Agent + skill definitions | 10 | `internal/httpserver/definitions.go:11-21` (`return 10`) | | Workflows | 2 | `internal/httpserver/workflows.go:87-88` (`return 2`) | | **Total registered** | **53** | | | `Server.Endpoints()` returns | **52** | health is registered before the sum | The 34 entity routes, by `Ops` bit: | Resource | List | Get | Create | Update | Delete | = | | --- | :-: | :-: | :-: | :-: | :-: | ---: | | JobPosting | ✓ | ✓ | ✓ | ✓ | | 4 | | JobApplication | ✓ | | ✓ | ✓ | ✓ | 4 | | AIInterview | ✓ | | ✓ | | | 2 | | Staff | ✓ | | ✓ | ✓ | | 3 | | WorkerProfile | ✓ | | ✓ | ✓ | | 3 | | Course | ✓ | ✓ | ✓ | ✓ | | 4 | | LearningPath | ✓ | | | | | 1 | | RoleCategory | ✓ | | ✓ | | | 2 | | Certification | ✓ | | ✓ | | ✓ | 3 | | UserActivity | ✓ | | ✓ | | | 2 | | Evidence | ✓ | | ✓ | ✓ | | 3 | | Assignment | ✓ | | ✓ | | | 2 | | ShiftRecord | ✓ | | | | | 1 | | Badge | | | | | | **0** | | | | | | | | **34** | `Badge` carries `Ops: 0` and an empty policy on purpose (`resources_gen.go` Badge block; `internal/domain/policy.go` `"badges": {}`). The descriptor exists so the seeder can populate the table. ### 1.2 Transport contract, common to every route - **Envelope in:** a bare JSON object. `decodeBody` (`internal/httpserver/api.go` `decodeBody` (:203)) caps the body at `maxBodyBytes = 4 MiB` (`api.go:15`) and reads it into an open `domain.Record`. `decodeInto` (`api.go` `decodeInto` (:183)) is the closed-struct variant used by login and assign. - **Envelope out, single record:** `{"data": {...}}` — `writeRecord`, `internal/httpserver/response.go:48`. - **Envelope out, collection:** `{"data": [...], "meta": {total, limit, offset, returned, truncated}}` — `writePage`, `response.go` `writePage` (:52). - **Errors:** `{"error": {code, message, details}}` — `writeError`, `response.go` `writeError` (:91). Status mapping in `statusFor`, `response.go` `statusFor` (:66): `unauthorized`→401, `forbidden`→403, `rate_limited`→429, `not_found`→404, `validation_failed`→422, `invalid_query`→400, `conflict`→409, else 500. - **404/405 from the mux** are rewritten into the same envelope by `jsonErrors` (`server.go` `jsonErrors`). - **Auth:** every path outside `publicPaths` (`auth.go` `publicPaths` (:289-293): `/health`, `/api/v1/auth/login`, `/api/v1/auth/logout`) requires the `krow_session` cookie. `authenticate` (`auth.go` `authenticate`) resolves it, re-reads the user row on **every** request, revokes on inactive, and puts `authctx.Identity` + `orgctx` on the context. - **Tenancy:** `org_id` is taken from the session user's row, never the request (`auth.go` `authenticate`, `internal/repo/repo.go` `Repo.Insert` (:367)). - **Authorization:** `Server.authorize` (`api.go` `Server.authorize` (:64)) reads `users.role`, consults `domain.Policy.Allows`, and answers **403 before any query runs**. Row visibility is a SQL predicate in `internal/repo`, so an invisible row answers **404**. ### 1.3 Health | Method | Path | Auth | Role | Request | Response | Tables | | --- | --- | --- | --- | --- | --- | --- | | GET | `/health` | **public** | — | — | `{"status": "ok"\|"degraded"\|"unavailable"}` (200/200/503) | `db.Check` reads `schema_migrations`, `information_schema` | Handler `server.go` `handleHealth`. The body is deliberately one field; all diagnostic detail goes to the log (`logHealth`, `server.go` `logHealth`). ### 1.4 Auth (2) | Method | Path | Auth | Role | Request | Response | | --- | --- | --- | --- | --- | --- | | POST | `/api/v1/auth/login` | public | — | `{email, password, remember_me}` (`loginRequest`, `auth.go` `loginRequest`) | 200 `{data: }` + `Set-Cookie: krow_session` | | POST | `/api/v1/auth/logout` | public | — | — | 200 `{data:{status:"signed_out"}}` + cookie cleared | - Login order (`auth.go` `handleLogin`): shape validation → **two** rate limiters (per-email and per-address, `internal/httpserver/ratelimit.go`) → constant-time credential verify (`internal/auth/credentials.go`) → session issue → `MarkLoggedIn` best-effort. - Every credential failure returns the identical 401 (`domain.Unauthenticated()`). - The token is **never** in the body — `auth.go` `handleLogin` states this explicitly. - Cookie attributes: `HttpOnly`, `Path=/`, `SameSite` from `sessionSameSite()` (`auth.go` `sessionSameSite` — `None` when a CORS allowlist is configured, else `Lax`), `Secure` when not development or when cross-site. - Tables: `users`, `sessions` (migration `000004`), `user_preferences`. ### 1.5 Current user (4) | Method | Path | Auth | Role | Request | Response | | --- | --- | --- | --- | --- | --- | | GET | `/api/v1/me` | session | any | — | `{data: user}` with `preferences` embedded | | PATCH | `/api/v1/me` | session | any | `{full_name?, account_type?}` | `{data: user}` | | GET | `/api/v1/me/preferences` | session | any | — | `{data: preferences}` | | PATCH | `/api/v1/me/preferences` | session | any | any JSON object | `{data: merged preferences}` | - Projection: `userColumns`, `me.go` `userColumns` (:33) — `id, legacy_id, full_name, email, role, account_type, status, created_date, updated_date`, plus `preferences`. - `PATCH /me` may write **only** `full_name` and `account_type` (`updatableUserFields`, `me.go` `updatableUserFields` (:54)). `role` was deliberately removed — the comment at `me.go` `updatableUserFields` (:54) records that leaving it in was a live privilege-escalation path. Attempts to write anything in `serverOwnedUserFields` (`me.go` `serverOwnedUserFields` (:69)) are ignored **and logged**. - Preferences are split: `owliverDefault`, `compactDensity`, `emailDigest` are columns (`preferenceColumns`, `me.go` `preferenceColumns` (:26)); **everything else lands in `user_preferences.extra` jsonb** — which is where `customSkills`, `customAgents`, `disabledSkills` and `removedSkills` live today. - `PATCH /me/preferences` is a shallow merge with an upsert (`me.go` `handlePreferencesPatch`). Booleans are type-checked; every other key is stored as-is, **with no size bound and no schema**. - Tables: `users`, `user_preferences`. ### 1.6 Entity CRUD (34) All 34 share four handlers — `handleList`, `handleGet`, `handleCreate`, `handleUpdate`, `handleDelete` (`internal/httpserver/api.go` `handleList`/`handleGet`/`handleCreate`/`handleUpdate`/`handleDelete`) — over one generic service (`internal/service/service.go`) and one generic repository (`internal/repo/repo.go`), driven by the descriptors in `internal/domain/resources_gen.go`. **Request shape, create/update:** an open JSON object of column names. Validation is `Service.validate` (`service.go` `Service.validate` (:204)): - an unknown field is **rejected** (`"unknown field"` → 422), not ignored; - a `ReadOnly` column in the body is **ignored**, not rejected; - `null` on a `NotNull` column is rejected; - an enum value outside `Column.Enum` is rejected with the permitted list; - on create, every `Required` column must be present and non-blank **unless `serverSupplies` says the session fills it** (`service.go` `serverSupplies` (:266)). **Response shape:** the full stored row, every column projected through `Column.SelectExpr` (`internal/domain/resource.go` `Column.SelectExpr` (:115)) — uuid→text, numeric→float8, date→`YYYY-MM-DD`, timestamptz→`YYYY-MM-DDTHH:MM:SS.mmmZ`, citext→text, bigint→text. **List parameters** (`Service.ParseList`, `service.go` `ParseList` (:55)): `sort` (prefix `-` for DESC; explicit empty `?sort=` means no ordering), `limit` (capped at `MaxLimit = 1000`, `service.go:28`), `offset`, and every other parameter is a field filter. Array and JSON columns are **not** filterable (`Resource.Filterable`, `resource.go` `Resource.Filterable` (:92)). Repeated params become `col = ANY(...)`. **Delete** always answers 200 with `{id}` whether or not a row matched (`service.go` `Service.Delete` (:185)) — deliberate, matching the old client store. Per-resource authorization, row scope and derived columns, all from `internal/domain/policy.go`: | Path | List | Get | Create | Update | Delete | Talent row scope | Server-derived on insert | | --- | --- | --- | --- | --- | --- | --- | --- | | `job-postings` | all | all | operators | operators | — | `ScopeActivePostings` on `status` | `created_by` ← user id | | `job-applications` | all | — | all | operators | operators | `ScopeEmail` on `email` | `email` ← session email (**talent only**) | | `ai-interviews` | all | — | all | — | — | `ScopeOwnApplications` via `application_id` | — (insert guarded, `repo.go:332`) | | `staff` | operators | — | operators | operators | — | n/a | — | | `worker-profiles` | all | — | all | all | — | `ScopeUserID` on `user_id` | `user_id` ← user id (**talent only**) | | `assignments` | all | — | operators | — | — | `ScopeEmail` on `worker_email` | — | | `shift-records` | all | — | — | — | — | `ScopeEmail` on `worker_email` | — | | `courses` | all | all | **admin** | **admin** | — | none | — | | `learning-paths` | all | — | — | — | — | none | — | | `role-categories` | all | — | operators | — | — | none | — | | `certifications` | all | — | operators | — | **admin** | none | — | | `user-activity` | all | — | all | — | — | `ScopeEmail` on `user_email` | `user_id`, `user_email`, `user_name`, `account_type` | | `evidence` | all | — | operators… | operators | — | `ScopeEmail` on `worker_email` | `worker_email` ← session email (**talent only**) | | `badges` | — | — | — | — | — | — | — | (`evidence` Create is `everyone`; Update is `operators`.) `all` = `{admin, employer, talent}`; `operators` = `{admin, employer}`. Backing tables are one-to-one with `Resource.Table` in `resources_gen.go`; all are created in `migrations/000001_initial_schema.up.sql`, except `job_applications.interview_id` (`000002`) and the dropped `job_applications_screened_consistent` CHECK (`000003`). ### 1.7 Agent and skill definitions (10) | Method | Path | Auth | Role | | --- | --- | --- | --- | | GET | `/api/v1/agent-definitions` | session | any (rows scoped) | | POST | `/api/v1/agent-definitions` | session | any; `visibility: organization` needs non-talent | | GET | `/api/v1/agent-definitions/{id}` | session | any (rows scoped) | | PATCH | `/api/v1/agent-definitions/{id}` | session | any own; organization rows need non-talent | | DELETE | `/api/v1/agent-definitions/{id}` | session | same as PATCH | | GET | `/api/v1/skill-definitions` | session | any (rows scoped) | | POST | `/api/v1/skill-definitions` | session | any; `visibility: organization` needs non-talent | | GET | `/api/v1/skill-definitions/{id}` | session | any (rows scoped) | | PATCH | `/api/v1/skill-definitions/{id}` | session | any own; organization rows need non-talent | | DELETE | `/api/v1/skill-definitions/{id}` | session | same as PATCH | Handlers `internal/httpserver/definitions.go`; service `internal/service/definitions.go`; repository `internal/repo/definitions.go`; parser/validator `internal/definition/`; schema `migrations/000005_agent_skill_definitions.up.sql`. **These are NOT `domain.Resource` values.** They are not in the policy table; their role checks are written inline in `internal/service/definitions.go` (`server.go` package doc states this explicitly). - **Create request:** `{"markdown": "", "visibility": "personal"|"organization"}`. `visibility` defaults to `personal` (`definitions.go` `CreateAgent` (:138)). **No projection field is accepted from the body** — `name`, `description`, `status`, `version`, `pages`, `definition_id` are all parsed out of the markdown (`definitions.go` `CreateAgent` (:138)). - **Update request:** `{"markdown": ...}` **or** `{"status": ...}`. Note the `else if` at `definitions.go:241` / `definitions.go:415`: **if `markdown` is present, a sibling `status` in the same PATCH is silently ignored.** `visibility` is immutable after creation (`definitions.go` `UpdateAgent` (:194)). - **Delete:** idempotent — a non-UUID or an invisible row returns `{"id": }` and 200 (`definitions.go` `DeleteAgent` (:253)). - **Response:** all columns — `id, definition_id, org_id, visibility, owner_user_id, created_by, markdown, status, version (agents only), name, description, pages, created_date, updated_date` (`repo/definitions.go` `agentDefinitionColumns` (:77) / `skillDefinitionColumns` (:92)). - **List parameters:** `visibility`, `status`, `definition_id`, `sort`, `limit`, `offset` — and nothing else; an unknown parameter is a 400 (`definitions.go` `allowedDefinitionFilters` (:17), `:44-49`). Sortable columns: `created_date`, `updated_date`, `name`, `definition_id`, `status`, and `version` for agents. Default `-created_date`, limit 100. - **Row visibility predicate**, applied on every read, update and delete (`repo/definitions.go` `GetAgent` (:182) and siblings): `org_id = :org AND (visibility='organization' OR (visibility='personal' AND owner_user_id = :user))`. - **Validation:** `definition.ValidateAgent` (`internal/definition/agent.go` `ValidateAgent` (:489)) and `definition.ValidateSkill` (`internal/definition/skill.go` `ValidateSkill` (:279)). ### 1.8 Workflows (2) | Method | Path | Auth | Required operations (all-or-nothing) | | --- | --- | --- | --- | | POST | `/api/v1/job-applications/{id}/hire` | session | `job-applications:Update` + `staff:Create` + `user-activity:Create` | | POST | `/api/v1/job-postings/{id}/assignments` | session | `assignments:Create` + `job-applications:Update` + `user-activity:Create` | Every pair resolves to `operators` today. Authorization is `authorizeAll` (`internal/httpserver/workflows.go` `authorizeAll` (:41)) — checked **before** any `BEGIN`. **Hire** (`internal/service/workflows.go` `WorkflowService.Hire` (:123)) - Request: optional `{phone?, role?, hire_date?, profile_tier?, status?, reviewer_name?}`. Anything else is ignored; every other staff field is carried from the application. - Response `201 {"data": {"application": {...}, "staff": {...}}}`. - Behaviour: reads the application **inside** the transaction; a second hire is a **409** (`workflows.go` `Hire`, the already-hired branch); missing name or email is a 422; writes staff, patches the application to `hired`, and writes a `user_activity` row — all in one transaction, all through `repo.New(res, tx)`. - Tables: `job_applications`, `staff`, `user_activity`. **Assign** (`internal/service/workflows.go` `WorkflowService.Assign` (:261)) - Request: `{"workers": [{worker_email, worker_name, starts_at, ends_at?, application_id?, worker_profile_id?, match_score?, source?}]}` — the closed struct `service.AssignRequest` (`workflows.go` `AssignWorker`/`AssignRequest` (:233/:245)). - Batch capped at `maxAssignmentBatch = 200` (`workflows.go:47`); the whole batch is validated before `BEGIN`. - Response `201 {"data": {"assignments": [...], "count": n}}`. - Behaviour per worker: insert `assignments`; **if and only if `application_id` was supplied**, patch that application to `assigned`; insert a `user_activity` row. Errors are annotated with the batch index (`workflows.go` `annotate` (:433)). - Tables: `job_postings` (read), `assignments`, `job_applications`, `user_activity`. --- ## 2. Frontend → existing backend mapping Traced from `krow-demo/docs/api-audit.md` §3–§9 against the router. ### 2.1 Already wired — EXISTS (39 routes exercised) | Frontend call site | Endpoint | Note | | --- | --- | --- | | `krowHooks.js:65,72` `useJobPostings/useJobPosting` | `GET /job-postings`, `GET /job-postings/{id}` | | | `KrowAssistant.jsx:366`, `CreatePosition.jsx:33-34` | `POST /job-postings`, `PATCH /job-postings/{id}` | | | `krowHooks.js:80` `useApplications` | `GET /job-applications` (± `job_posting_id`) | | | `krowHooks.js:202,213` | `POST`/`PATCH /job-applications/{id}` | | | Candidate removal paths | `DELETE /job-applications/{id}` | | | `krowHooks.js:90`, `AIInterviewModal.jsx:159` | `GET`/`POST /ai-interviews` | | | `krowHooks.js:112`, hire path | `GET`/`POST`/`PATCH /staff` | | | `krowHooks.js:583,589` | `GET`/`POST`/`PATCH /worker-profiles` | | | `krowHooks.js:345,349,362,371` | `GET`/`POST`/`PATCH /courses`, `GET /courses/{id}` | | | `krowHooks.js:533` | `GET /learning-paths` | | | `CreatePosition.jsx:38-39` | `GET`/`POST /role-categories` | | | `CertificationManager.jsx:10-11` | `GET`/`POST`/`DELETE /certifications` | | | `userTracking.js:45`, `UserTracking.jsx:10` | `GET`/`POST /user-activity` | | | `krowHooks.js:629,639` | `GET`/`POST`/`PATCH /evidence` | | | `krowHooks.js:388`, `useAssignWorkers` | `GET`/`POST /assignments` | | | `krowHooks.js:105` | `GET /shift-records` | | | `base44Client.js` the shared hydration promise (:130) + 11 call sites | `GET /me` | one shared hydration promise | | `auth.updateMe`, `Profile.jsx` | `PATCH /me` | | | `auth.updatePreferences`, `useUpdatePreferences` | `PATCH /me/preferences` | | | `base44Client.js` `auth.login/logout` | `POST /auth/login`, `POST /auth/logout` | | ### 2.2 Backend exists, frontend does not use it — WIRE (13 routes) | Endpoint | What the frontend does instead | Section | | --- | --- | --- | | `POST /job-applications/{id}/hire` | `useHireCandidate` — two un-transactional calls, `krowHooks.js` `useHireCandidate` (:296) | §3.1 | | `POST /job-postings/{id}/assignments` | `useAssignWorkers` — 3n calls in a loop, `krowHooks.js` `useAssignWorkers` (:406) | §3.2 | | `GET`/`POST`/`GET {id}`/`PATCH {id}`/`DELETE {id}` `/agent-definitions` (5) | `preferences.customAgents`, `lib/agents/useAgents.js` `stored` (:29), `save` (:62), `remove` (:75) | §3.3 | | `GET`/`POST`/`GET {id}`/`PATCH {id}`/`DELETE {id}` `/skill-definitions` (5) | `preferences.customSkills`, 104 references across 23 files | §3.4 | | `GET /me/preferences` | reads preferences off `GET /me` + the `krow_demo_user` mirror | **not a gap** — `auth.preferences()` must answer synchronously (`api-audit.md` §5.3) | Also unused but **not** a gap: `GET /health` (registered and proxied by `vite.config.js`; no frontend code calls it). ### 2.3 Frontend dummy / localStorage / client-only | Thing | Where | Server today | Verdict | | --- | --- | --- | --- | | `integrations.Core.InvokeLLM` | `src/api/aiEngine.js`; 10 consumers in `krowAi.js` + `provingGround.js` | none | **NEW** — §10.1 | | `integrations.Core.UploadFile` → `blob:` URL | `aiEngine.js`; `KrowIdentity.jsx`, `MediaChallenge.jsx`, `VideoRecorder.jsx` | none | **NEW** — §10.2 | | Assistant stream | `components/ai-assistant/provider.js:205`; `createLocalProvider()` runs because `VITE_ASSISTANT_ENDPOINT` is unset | none | **NEW** — §10.3 | | Web search | agent `webSearch:` flag; the UI states plainly that no provider is configured | none | **NEW** — §10.4 | | `analytics.track` | `base44Client.js` `analytics.track` (:287), DEV-only `console.debug` | none | **not a gap** — real audit logging is `POST /user-activity` | | `customSkills`, `customAgents` | `user_preferences.extra` via `PATCH /me/preferences` | tables exist and are unused | **WIRE** — §3.3/§3.4 | | `disabledSkills`, `removedSkills` | `user_preferences.extra` | correct location | **not a gap** — migration `000005` header says so verbatim | | `krow_demo_user`, `krow_assistant:history`, `krow_assistant_open/expanded/width`, `krow_assistant:agent`, per-context thread keys | localStorage / sessionStorage | none | **not a gap** — UI state; `api-audit.md` §9 | | `ROLE_CATEGORIES` (7 strings) | `src/lib/roleCategories.js:9` | `GET /role-categories` returns the org's own | **not dummy data bypassing the API** — the static list is merged *on top of* the fetched list at both call sites; see §4.2 | | `src/api/seed.js` (1,928 lines) | retained for `DEMO_USER.preferences` only | entity data is in PostgreSQL | dead but harmless | | `src/api/store.js` (173 lines) | imported by nothing | — | dead | | `useBadges` (`krowHooks.js:537`) | zero consumers; `Ops: 0` server-side | — | dead; would 404 | | `User: 'users'` in `RESOURCE_PATHS` (`httpClient.js:96`) | no call site; no backend resource | — | dead | | `meta.truncated` | `httpClient.js:226` returns `payload.data` and **drops `meta` entirely** | server already sends it | **WIRE** — §9.3 | ### 2.4 Genuinely missing backend capability Only four, and all four are in §10: LLM inference, file storage, assistant streaming, web search. **Nothing the frontend calls today is missing from the server.** --- ## 3. Existing APIs that must be adopted ### 3.1 Hire — `POST /api/v1/job-applications/{id}/hire` **What the frontend does now** (`krowHooks.js` `useHireCandidate` (:296), `useHireCandidate`): 1. computes `tier` from `application.ai_score` (`>=80` Skilled, `>=60` Cross-Trained, else Beginner); 2. `PATCH /job-applications/{id}` `{status: 'hired'}`; 3. `POST /staff` with name, email, phone, `role: job?.title || job?.role_category || 'Staff'`, `profile_tier: tier`, today's `hire_date`, `application_id`, `job_posting_id`, `ai_score`, `status: 'onboarding'`; 4. `logActivity('hire_candidate')` → `POST /user-activity`. A failure between 2 and 3 leaves an application marked `hired` with no staff row, and nothing notices. **What the backend already supports** (`internal/service/workflows.go` `WorkflowService.Hire` (:123)): all four writes in one transaction, a 409 on a double hire, and an all-or-nothing role check before `BEGIN`. **Three behavioural differences that must be resolved before adoption:** | # | Difference | Resolution | | --- | --- | --- | | H-1 | Backend writes `event_type: "candidate_hired"` (`workflows.go:212`). The frontend vocabulary is `hire_candidate` — and `PRIVILEGED_EVENTS = ['hire_candidate', 'create_position']` (`src/lib/activitySignals.js:31`), read by `activity.signals` and `activity.breakdown`. The seed fixture also uses `hire_candidate` (`seed/fixtures/seed.json:4763`). Adopting as-is **silently breaks anomaly detection and the activity feed's labels.** | **CHANGE** `workflows.go:212` to `hire_candidate`. | | H-2 | Backend defaults `profile_tier` to `"Beginner"` (`workflows.go` `Hire`, the staff record literal); the frontend derives it from `ai_score`. | Frontend sends `profile_tier` in the body — the field is already accepted by `pick()`. No server change. | | H-3 | Backend sets `role` from `app["job_title"]`; the frontend prefers the posting's `title`, then `role_category`, then `"Staff"`. | Frontend sends `role` in the body. No server change. | **Adoption plan:** replace the body of `useHireCandidate` with one `POST /job-applications/{id}/hire` carrying `{role, profile_tier}`; drop the separate `logActivity('hire_candidate')` (the workflow writes it); keep the same `onSuccess` invalidations plus `['userActivity']`. ### 3.2 Assign — `POST /api/v1/job-postings/{id}/assignments` **What the frontend does now** (`krowHooks.js` `useAssignWorkers` (:406), `useAssignWorkers`), per worker, sequentially, with no transaction: 1. `POST /assignments`; 2. look for an existing application on `(job_posting_id, lower(email))`; if none, **`POST /job-applications`** with the worker's profile fields and `status: 'assigned'`; if one exists, `PATCH` it to `assigned`; 3. `logActivity('assign_employee', {position_id, application_id, worker_email})`. **What the backend already supports** (`internal/service/workflows.go` `WorkflowService.Assign` (:261)): the whole batch in one transaction, capped at 200, validated before `BEGIN`, errors annotated by index. **Two blocking differences:** | # | Difference | Resolution | | --- | --- | --- | | A-1 | **The backend never creates a `JobApplication`.** It patches one only when `application_id` is supplied (`workflows.go` `Assign`, the application branch). The frontend's own comment (`krowHooks.js` `useAssignWorkers`, the create-if-absent comment) explains why creation is required: an assigned worker with no application is unreachable — nothing to open, and no subject for an interview. Adopting the endpoint as-is **loses that behaviour**. | **CHANGE** — see below. This is the single largest existing-code change in the plan. | | A-2 | Backend writes `event_type: "worker_assigned"` (`workflows.go:373`); the frontend vocabulary is `assign_employee`. | **CHANGE** `workflows.go:373` to `assign_employee`. | **Recommended resolution for A-1 — extend the existing endpoint, do not add one.** `service.AssignWorker` gains one optional field, `application: {...}`, carrying the same fields the frontend builds at `krowHooks.js` `useAssignWorkers`, the application literal. Inside the transaction, per worker: - `application_id` supplied → patch it to `assigned` (unchanged); - `application_id` absent **and** `application` supplied → look the application up by `(job_posting_id, email)` — the pair is already `UNIQUE` (`job_applications_posting_email_key`, migration `000001`) and already indexed (`job_applications_org_email_idx`) — patch it if found, insert it if not, then link the assignment to it; - neither → insert the assignment with no application link (today's behaviour, preserved for the talent-pool case the code comments describe). Because the handler may now insert a `job_applications` row, the authorization requirement list at `internal/httpserver/workflows.go` `handleAssign` (:127) must gain `{"job-applications", domain.OpCreate}`. It resolves to `operators`, so no caller's effective permissions change — but the check must be written down or the workflow performs a write it was not authorized for. **Files to change:** `go-api/internal/service/workflows.go`, `go-api/internal/httpserver/workflows.go`. **No migration. No new route.** ### 3.3 Agent definitions — 5 endpoints **What the frontend does now.** `useAgents` (`src/lib/agents/useAgents.js`) is the single writer for every agent screen. It reads `preferences.customAgents` (`:29`) — an array of `{path: 'custom/.md', raw: }` — runs `readAgentRegistry(stored, {customSkills})`, and every mutation (`save`, `remove`, `publish`, `archive`, `restore`, `duplicate`) ends in `updatePreferences.mutateAsync({customAgents: next})` → `PATCH /me/preferences` → `user_preferences.extra`. Consumers: `pages/admin/Workspace.jsx`, `pages/admin/AgentDetail.jsx`, `components/ai-assistant/AgentContext.jsx`, `lib/agents/customAgents.js`. **What the backend already supports.** Everything above, with tenancy and ownership: | Frontend operation | Endpoint | Server-side equivalent | | --- | --- | --- | | `save(source)` — new | `POST /agent-definitions` `{markdown, visibility}` | validates, parses, projects `definition_id/name/description/status/version/pages` | | `save(source)` — existing | `PATCH /agent-definitions/{id}` `{markdown}` | re-validates and re-projects every column | | `remove(id)` | `DELETE /agent-definitions/{id}` | idempotent | | `publish(id)` | `PATCH` with the republished markdown | `agentLifecycle.publishAgent` already writes `status`/`version` into the frontmatter; the server re-parses both | | `archive`/`restore` | `PATCH` with the rewritten markdown, or `{status}` | server accepts `draft`/`published`/`archived` | | `sourceFor(id)` | `GET /agent-definitions/{id}` or list + `markdown` | markdown is returned verbatim | | shadowing shipped ids | `definition_id` is unique **per owner** and **per org**, never globally | `agent_definitions_personal_key` / `_org_key`, migration `000005` | `validateAgentSource` in the browser and `definition.ValidateAgent` (`internal/definition/agent.go:489`) are held to byte-identical messages by `internal/definition/conformance_test.go`, which replays the real JavaScript parser's output over all 37 shipped definitions plus `testdata/oracle.json`. **One conflict-detection gap to be aware of when wiring.** `publish` compares against `live.version` held in the browser (`useAgents.js` `publish` (:90)). The server has no optimistic-concurrency predicate on `PATCH` — `UpdateAgent` (`repo/definitions.go` `UpdateAgent` (:270)) writes unconditionally. Two tabs publishing concurrently: last write wins. This is **not** a regression (preferences had no concurrency control either) and is listed in §12 Phase 3 as an optional follow-up, not a blocker. ### 3.4 Skill definitions — 5 endpoints Identical shape, minus `version`. The frontend writer is `src/lib/skills/customSkills.js` (`upsertCustomSkill`, `removeCustomSkill`, `customSkillSource`) plus `usePageSkills` / `useWorkforcePaths` (`src/lib/skills/usePageSkills.js`), which read `preferences.customSkills` and `preferences.disabledSkills` on **every page render**. 104 references across 23 files write or read `customSkills`: `pages/admin/{Workspace,WorkspaceSkills,SkillEditor,OwliverSkillEditor,AgentDetail}.jsx`, `components/skills/SkillSurface.jsx`, `components/agents/*`, `components/ai-assistant/{KrowAssistant,useAssistant,routing,AgentContext}.jsx`, `lib/skills/{catalog,tools,usePageSkills}.js`, `lib/agents/{registry,capabilityTest}.js`. Mapping is one-to-one with §3.3, with `status` ∈ `{active, inactive}` (`SkillStatuses`, `internal/definition/vocabulary.go` `SkillStatuses` (:128)) and no `version` column — migration `000005` states verbatim that inventing one would be inventing a concept the frontend does not have. **What stays in preferences, deliberately:** `disabledSkills` and `removedSkills`. They are arrays of skill **ids**, mostly *shipped* ids, so they are suppression preferences over a namespace rather than definitions. Migration `000005`'s header says exactly this. **Do not migrate them.** ### 3.5 What adoption of §3.3/§3.4 buys, per migration `000005` | Column | What the jsonb blob had | What the table gives | | --- | --- | --- | | `markdown` | a string in an array | the authoritative artefact, `length BETWEEN 1 AND 65536` | | `definition_id` | derived by re-parsing every entry | a column, `CHECK (~ '^[a-z0-9][a-z0-9-]*$')`, indexed, unique per tier | | `visibility` | none — everything was personal | `personal` \| `organization` with a CHECK | | `owner_user_id` / `org_id` | none | tenancy + ownership; personal rows CASCADE with their owner | | `status` | parsed | a column, CHECK-constrained, partial-indexed for the runtime's own query | | `version` (agents) | parsed | a column, `CHECK (version >= 1)`, sortable | | `name`/`description`/`pages` | parsed on every read | server-parsed projections, never accepted from a body | | size | unbounded, and **returned in full by `GET /me` on every page load** | bounded, and off the `/me` hot path | --- ## 4. Create Position / Jobs ### 4.1 The complete CRUD flow, verified | Step | Frontend | Endpoint | Server | | --- | --- | --- | --- | | List | `useJobPostings` (`krowHooks.js:65-70`) `list('-created_date', 100)` | `GET /job-postings` | default sort `-created_date`, default limit 100 — `resources_gen.go` JobPosting | | Read one | `useJobPosting(id)` (`krowHooks.js:72-78`) | `GET /job-postings/{id}` | `Ops` includes `OpGet` | | Create | `useCreateJobPosting`; `CreatePosition.jsx:33`; also `KrowAssistant.jsx` `createPosition` (:366) (Owliver's guided flow) | `POST /job-postings` | `created_by` derived from the session (`policy.go`); `title` is the only `Required` column | | Update | `useUpdateJobPosting`; `CreatePosition.jsx:34`; `useGenerateJobDescription` patches a draft (`krowHooks.js` `useGenerateJobDescription` (:276)) | `PATCH /job-postings/{id}` | shallow merge, `service.go` `Service.Update` (:161) | | Close | status transition to `closed` | `PATCH` | `posting_status` enum, migration `000001:34` | | Delete | **no call site** | — | `Ops` has no `OpDelete` → the mux answers 405. Correct. | Talent callers see only `status = 'active'` rows — `TalentScope: {Kind: ScopeActivePostings, Column: "status"}` (`policy.go`), so a draft is a 404 to them, never a 403. `vetting_criteria` and `skill_requirements` are `jsonb NOT NULL` with a GIN index on `skill_requirements` (`migrations/000001_initial_schema.up.sql:281`). The Create Position form writes both through `toPositionPayload` (`src/lib/positionModel.js`). ### 4.2 Dummy data still bypassing the real API **One candidate, and it is not a bypass.** `ROLE_CATEGORIES` (`src/lib/roleCategories.js:9`) is a static array of seven strings. Its own docstring records that custom categories come from the `RoleCategory` entity and are merged on top at both call sites — `CreatePosition.jsx:38` reads `useRoleCategories()` and `KrowAssistant.jsx:271` does the same. The static list is a **seed vocabulary offered as options**, not a replacement for the fetched list, and `POST /role-categories` is wired (`CreatePosition.jsx:39`). *If* the seven defaults are wanted as real rows, the correct move is a **seed change** to `seed/fixtures/seed.json` plus deleting the constant in the frontend — not a backend change. That is optional and is listed in Phase 1 as such. No other dummy data was found on this flow. `Overview.jsx:48-57`, `Positions.jsx`, `PositionDetail.jsx` and `CreatePosition.jsx` all read live query hooks. ### 4.3 Are any backend changes required for Create Position? **No.** The four registered routes cover every call site, the defaults match, the enums match, `created_by` is derived, and delete is correctly absent. The only work on this flow is the **AI description generation** — `useGenerateJobDescription` calls the local `generateJobDescription` stub (`src/lib/krowAi.js`) before it PATCHes the draft. That is §10.1, not a job-posting problem. --- ## 5. Applications / Interviews / Staff / Talent / Assignments ### 5.1 Applications | Flow | Endpoint | Status | | --- | --- | --- | | Talent applies (`Apply.jsx:17`) | `POST /job-applications` | **EXISTS.** `email` is derived from the session for talent (`policy.go`), so an applicant cannot file under someone else's address. | | Operator screens one (`useScreenCandidate`, `krowHooks.js` `useScreenCandidate` (:223)) | `PATCH /job-applications/{id}` | **EXISTS.** Writes `status: 'ai_screened'` plus seven `ai_*` fields. | | Operator screens all (`useScreenAllCandidates`, `krowHooks.js` `useScreenAllCandidates` (:245)) | n × `PATCH` | **EXISTS, and correctly not a workflow** — `internal/service/workflows.go` file header records the reasoning: a partial screen is not a corrupt state. | | Move to interview (`useMarkInterviewReady`, `krowHooks.js` `useMarkInterviewReady` (:507)) | `PATCH` + `POST /user-activity` | **EXISTS.** | | Remove | `DELETE /job-applications/{id}` | **EXISTS**, operators only, idempotent 200. | **Two defects found.** - **AP-1 — missing validation, wrong layer. CHANGE.** `Service.serverSupplies` (`go-api/internal/service/service.go` `serverSupplies` (:266)) iterates `Policy.Derived` and **ignores `Derived.TalentOnly`**. For `job-applications`, `email` is `Required` *and* `Derived{TalentOnly: true}`. Consequence: an **operator** who POSTs an application with no `email` skips the service's required-field check entirely, reaches SQL, and trips `email citext NOT NULL`. `translate` (`repo/repo.go` `translate` (:673)) turns that into a 422 `"email must not be null"`, so the status code is right by accident, but the message and the `details` key differ from every other required-field refusal and the check is one layer too late. **Fix:** give `serverSupplies` the caller's role and skip a `TalentOnly` derivation for non-talent callers. Same defect applies to `evidence.worker_email` and would apply to any future `TalentOnly` required column. One file: `go-api/internal/service/service.go`. No migration. - **AP-2 — `screened_at` is written by nobody.** Migration `000003` dropped `job_applications_screened_consistent` and its header explains why: the column came from the blueprint, not from repository evidence, and 9 of 24 seeded applications were being rejected by it. The column survives, nullable and unused. **No action.** Recorded so nobody re-adds the constraint. ### 5.2 Interviews — one real RBAC break `AIInterviewModal.finishInterview` (`src/components/krow/AIInterviewModal.jsx` `finishInterview` (:155)) does two writes with no transaction: 1. `POST /ai-interviews` — the transcript, scores and verdict; 2. `PATCH /job-applications/{id}` `{status:'interview', interview_id, ai_score}`. **IV-1 — a talent user cannot complete an interview. CHANGE.** `Apply.jsx` renders this modal for the talent flow. The policy table allows `ai-interviews:Create` to **everyone** but `job-applications:Update` to **operators only** (`internal/domain/policy.go`). So step 1 succeeds and step 2 returns **403**: the interview row exists, the application still says `applied`, and `interview_id` is never set. `InterviewAnalytics.jsx:9` and `AnalyticsMetrics.jsx:8` both count `status === 'interview' || a.interview_id`, so the interview is invisible to analytics afterwards. **IV-2 — the two writes are not atomic**, even for an operator. A failure between them leaves an orphan `ai_interviews` row. **Recommended fix — reuse the existing route, do not add one.** Make `POST /api/v1/ai-interviews` link its application in the same transaction: after inserting the interview, update the named `application_id` to `{status:'interview', interview_id: , ai_score: overall_interview_score}` — performed by the **server** on the row the interview already names, so no widening of `job-applications:Update` is needed and a talent caller still cannot patch an application directly. The insert is already guarded: `Repo.guardInsert` (`go-api/internal/repo/repo.go` `guardInsert` (:332)) refuses a talent caller creating an interview for an application that is not theirs, answering 404. That guard is exactly the ownership proof the linked update needs. - **Files:** `go-api/internal/service/service.go` (or a small `go-api/internal/service/interviews.go` holding the special-cased Create for this one resource), and `go-api/internal/httpserver/api.go` only if the handler needs a transaction begun for it. Prefer routing this resource's `Create` through the transactional path already built in `go-api/internal/service/workflows.go` (`WorkflowService.inTx`). - **Alternative, rejected:** adding `talent` to `job-applications:Update` with a column allowlist. Rejected because it puts a second authorization mechanism (per-column) beside the existing per-operation one, which `internal/httpserver/workflows.go` file header explicitly argues against. - **No new route. No migration.** `ai_interviews` has no `OpUpdate` and no `OpGet` — correct: the record is written once, and every consumer reads it from the list. ### 5.3 Staff `GET`/`POST`/`PATCH /staff`, operators only. `HiredHistory.jsx:41-42` reads staff and postings and writes ratings/reviews through `PATCH`. **EXISTS, complete.** Adopting §3.1 makes the create half transactional. ### 5.4 Talent / worker profiles | Flow | Endpoint | Status | | --- | --- | --- | | `useWorkerProfile` — read-or-create on first access (`krowHooks.js` `useWorkerProfile` (:548)) | `GET /worker-profiles?email=&limit=1` then `POST` | **EXISTS.** `user_id` derived for talent. | | `useWorkerProfiles` (`krowHooks.js` `useWorkerProfiles` (:580)) | `GET /worker-profiles` `-krow_score` 500 | **EXISTS**, matches the server default exactly. | | `useUpdateWorkerProfile`, `useCompleteCourse`, `useSubmitChallenge` | `PATCH /worker-profiles/{id}` | **EXISTS.** Shallow merge replaces whole arrays, which `useSubmitChallenge` relies on (`repo/repo.go` `Repo.Update` (:429)). | | Evidence submit (`useSubmitChallenge`, `krowHooks.js` `useSubmitChallenge` (:639)) | `POST /evidence` then `PATCH /worker-profiles/{id}` | **EXISTS**, but two writes with no transaction. `workflows.go` file header names this as deliberately deferred. Low risk: a failed profile patch loses XP, not the evidence. **No change proposed.** | | Supervisor verify (`useVerifyEvidence`) | `PATCH /evidence/{id}` | **EXISTS**, operators only. | **TP-1 — a profile created by an operator locks its own subject out. CHANGE (deferred).** `worker_profiles.user_id` is derived **talent-only** (`policy.go`), so a profile an admin creates for a candidate has `user_id = NULL`. The talent row scope is `ScopeUserID` on `user_id`, so that person's `GET /worker-profiles?email=...` returns **empty** — the row is invisible to them. `useWorkerProfile` (`krowHooks.js` `useWorkerProfile` (:548)) reads that empty list as "no profile yet" and POSTs one, which trips `worker_profiles_org_email_key UNIQUE (org_id, email)` (`migrations/000001_initial_schema.up.sql:334`). `translate` (`repo/repo.go` `translate` (:673)) turns the unique violation into a **409 conflict**, and the query throws. So the failure is not a duplicate row — the constraint prevents that correctly — it is a talent user who can neither see nor create their profile, with no path out from the UI. It is latent today only because the seed fixture attaches `user_id` to every profile it writes. The fix needs a product decision (may an operator claim a profile on someone's behalf, and does signing in adopt an unclaimed one?), so it is scheduled in Phase 7 rather than earlier. Two candidate shapes, both without a new route: (a) widen the talent read scope to `user_id = :user OR (user_id IS NULL AND email = :email)` in `internal/domain/policy.go` + `internal/repo/repo.go`, and adopt the row on first write; or (b) claim it at login in `internal/httpserver/auth.go`. (a) is preferred — it is a predicate change in the layer that already owns predicates. See §11 for the DB impact of each. **TP-2 — no role gate on the admin console. Frontend.** `AdminRoute.jsx` `AdminRoute` (:23) checks `isAuthenticated` and explicitly notes that a role check "will go in Phase 3D". It has not. An `employer` who signs in at `/admin/login` reaches KROW Forge (`University.jsx:48-49`), whose `POST`/`PATCH /courses` are **admin-only** (`policy.go` — employer is excluded on purpose, because a NULL-`org_id` course is the shared platform library). The same applies to `DELETE /certifications`. Result today: a 403 toast where the UI offers an action. **Frontend work; the server is right.** ### 5.5 Assignments | Flow | Endpoint | Status | | --- | --- | --- | | List | `GET /assignments` `-created_date` 500 | **EXISTS**, matches. | | Create | `POST /assignments` in a 3n loop | **WIRE** → `POST /job-postings/{id}/assignments`, after the A-1/A-2 changes in §3.2. | | Update / cancel | — | **Not supported and not called.** `Ops` is `List|Create`; nothing in the frontend cancels an assignment. Do not add one speculatively. | One cosmetic difference to settle while wiring §3.2: the column default is `source = 'owliver'` (`migrations/000001_initial_schema.up.sql:511`) and the frontend sends `'owliver'`, but `WorkflowService.Assign` substitutes `'manual'` for an empty value (`internal/service/workflows.go:336`). Harmless while the frontend always sends the field; worth aligning in the same edit. The schema is ahead of the API here in a good way: `assignments_period_gist` (`migrations/000001_initial_schema.up.sql:523`) already indexes the `[starts_at, ends_at)` period, which is what an overlap check would need if double-booking ever becomes a rule. No such rule exists in the frontend today, so none is proposed. --- ## 6. Owliver / Agents / Skills — full audit ### 6.1 The two tables `migrations/000005_agent_skill_definitions.up.sql` creates `agent_definitions` and `skill_definitions`. Two tables and not one, per its own header: agents have `status ∈ {draft, published, archived}` plus a monotonic integer `version`; skills have `status ∈ {active, inactive}` and **no version at all**. Deliberately absent, and named as such in the migration: `definition_versions`, `definition_permissions`, `agent_skills`, `agent_subagents`, `agent_knowledge`, `conversations`. **Do not add any of them in this plan.** `permissions:` is parsed (`internal/definition/agent.go` `normalizePermissions`) and unenforced by design. ### 6.2 Ownership and tenancy — verified | Property | Where enforced | Verdict | | --- | --- | --- | | `org_id NOT NULL` on **both** tiers, including personal | migration `000005`; `service/definitions.go` `CreateAgent` (:138) sets it from `ident.OrgID` | correct — a personal definition is always inside a tenant | | `owner_user_id` set **iff** `visibility='personal'` | `CHECK ((visibility='personal') = (owner_user_id IS NOT NULL))` | correct, and stated in one line so it cannot be half-satisfied | | personal definitions cascade with the owner | `owner_user_id ... ON DELETE CASCADE` | correct | | shared definitions survive their author | `created_by ... ON DELETE SET NULL` | correct | | every read/write carries the org + ownership predicate | `repo/definitions.go` — `ListAgents/GetAgent/UpdateAgent/DeleteAgent` and the four skill twins | correct; an invisible row answers 404, never 403 | | a talent user cannot author an organization definition | `service/definitions.go` `CreateAgent` (:138) (create), `:207-212` (update), `:264-269` (delete), ×2 for skills | correct | | `visibility` is immutable after create | `service/definitions.go` `UpdateAgent` (:194), `:415-419` | correct | | a definition cannot name its own tenancy | `internal/definition/definition.go` package doc — the frontend ignores `visibility:` in frontmatter entirely | correct | ### 6.3 Shadow-by-id — verified `definition_id` is **not** globally unique. Two partial unique indexes: `(owner_user_id, definition_id) WHERE visibility='personal'` and `(org_id, definition_id) WHERE visibility='organization'`. So a personal definition may carry the same id as an organization one, which may carry the same id as a *shipped* one in `krow-demo/src/skills/` — which is exactly the override mechanism `useAgents.isOverridden` (`useAgents.js:41`) already reports as `shadowed`. Resolution precedence is implemented: `GetAgentByDefinitionID` / `GetSkillByDefinitionID` (`repo/definitions.go` `GetAgentByDefinitionID` (:209), `:430-461`) order by `CASE WHEN visibility='personal' THEN 1 ELSE 2 END LIMIT 1` — personal wins. **These two functions are reachable only from `internal/runtime`, which is not wired to any route** (see §6.7). ### 6.4 Status, versioning - Agents: `status` CHECK `('draft','published','archived')`; `version integer NOT NULL DEFAULT 1 CHECK (version >= 1)`; partial index `agent_definitions_published_idx ... WHERE status='published'`. - Skills: `status` CHECK `('active','inactive')`; partial index `skill_definitions_active_idx ... WHERE status='active'`. No version. - Both are **projections parsed out of the markdown**, never accepted from a body — `service/definitions.go` `CreateAgent` (:138) builds the insert input entirely from `definition.ParseAgent` / `ParseSkill` output. - `PATCH {status}` is accepted as a shortcut *only when `markdown` is absent* — the `else if` at `service/definitions.go:241` and `:415`. **Gap D-1 (CHANGE, minor):** a PATCH carrying both `markdown` and `status` silently drops the `status`. It should either apply the markdown's parsed status (which it does) *and say so*, or refuse the combination as a 422. A silent drop is the one outcome that teaches nobody anything. - **No optimistic concurrency.** `UpdateAgent` (`repo/definitions.go` `UpdateAgent` (:270)) writes unconditionally; `useAgents.publish` compares versions in the browser (`useAgents.js` `publish` (:90)). Two tabs publishing concurrently: last write wins. Not a regression from preferences. Optional Phase 3 follow-up: add `AND version < :new_version` to the agent update predicate and answer 409 on zero rows — a change to `repo/definitions.go` only, no migration. ### 6.5 Markdown parsing and validation `internal/definition/` is a deliberate port of the browser's parsers: | Concern | Backend | Frontend counterpart | | --- | --- | --- | | document layer (BOM, line endings, fences, body) | `frontmatter.go` | `src/lib/skills/registry.js` | | YAML subset (no YAML dependency, on purpose) | `yaml.go` | `src/lib/skills/yaml.js` | | JS value semantics (`jsString`, `jsTruthy`, `jsNumber`, `jsTrim`) | `jsvalue.go` | JavaScript itself | | agent parse + normalize | `agent.go` — port of `parseAgent` + `normalizeAgent` | `src/lib/agents/registry.js`, `agentConfig.js` | | skill parse | `skill.go` — port of `parseSkill` | `src/lib/skills/registry.js` | | closed vocabularies | `vocabulary.go` | `src/lib/skills/surfaces.js`, `src/lib/agents/vocabulary.js` | Compatibility is enforced, not asserted: `internal/definition/conformance_test.go` replays the **actual** JavaScript parser output (captured into `testdata/oracle.json` by `scripts/oracle.mjs`) against the Go parser over all 37 shipped definitions plus every adversarial case. Deliberate asymmetries, all documented in source and **not to be "fixed"**: - `Skill.Pages` keeps the strings **as the author wrote them**; `Agent.Pages` are **canonicalised** (`skill.go` `Skill.Pages` (:28), `agent.go` `Agent.Pages` (:66)). The two frontend parsers differ the same way; reconciling here would make each side disagree with its own editor. - `pages: candidates` (a bare scalar) is accepted for an **agent** and refused for a **skill**, because `normalizeAgent` coerces and `parseSkill` requires a sequence (`agent.go` `asList` (:112)). - A skill's `status:` is a **coercion**, not a check — anything that is not `inactive` reads as `active` (`skill.go` `ParseSkill` (:107)). Three rules are the backend's own, all listed as `BackendOnly` on the `Rejection`: 1. `MaxMarkdownLength = 65536` characters — the `markdown_size` CHECK, which the editor does not enforce (`definition.go:73`, `skill.go` `ValidateSkill` (:279), `agent.go` `ValidateAgent` (:489)). 2. `MaxVersion = 2147483647` — `agent_definitions.version` is a PostgreSQL `integer`; the editor accepts any whole number ≥ 1 (`agent.go` `ValidateAgent` (:489)). 3. "A definition with a `ui:` block needs an explicit `pages:` list" (`skill.go` `ValidateSkill` (:279)) — because `parseSkill` falls back to the `ui:` block's own pages, a fallback the backend cannot compute (§6.6). ### 6.6 Backend validation vs. the frontend Owliver vocabulary This is the sharpest boundary in the system, and it is drawn on purpose. **What the backend mirrors exactly.** `SKILL_SURFACES` — all **18** ids and their aliases (`create-position`←`new-position`, `hired-history`←`hired`, `krow-forge`←`university`/`forge`) — are transcribed into `internal/definition/vocabulary.go` `pageSurfaces` (:18), and `normalizeKey` (`vocabulary.go` `normalizeKey` (:70)) reproduces `surfaces.js`'s own normalization (lower-case, spaces and underscores read as dashes). Verified against the live frontend: `python3` over `src/lib/skills/surfaces.js` yields exactly those 18 ids in that order. The agent vocabularies — statuses, reasoning modes, knowledge kinds, access, permission roles, the 10 icons — are transcribed at `vocabulary.go` the agent vocabulary block (:94-113). **What the backend does not read at all.** `internal/definition/skill.go` `deferredBlocks` (:235) (`deferredBlocks`) records the presence of `ui:` and `owliver:` on `Skill.Deferred` and checks **nothing inside them**. | Frontend vocabulary | Count | Source of truth | Backend equivalent | | --- | ---: | --- | --- | | Page surfaces | 18 | `src/lib/skills/surfaces.js` `SKILL_SURFACES` | **mirrored** — `vocabulary.go` `pageSurfaces` (:18) | | Owliver capabilities | 10 | `surfaces.js` `OWLIVER_CAPABILITIES` | **none** | | Data sources | 25 | `surfaces.js` `DATA_SOURCES` | **none** | | Section types | 9 | `surfaces.js` `SECTION_TYPES` | **none** | | Periods | 6 | `surfaces.js` `PERIODS` | **none** | | Placement→context rules | — | `surfaces.js` `placementProvides()` | **none** | | `owliver:` normalization | — | `src/lib/skills/owliverConfig.js` | **none** | | section normalization | — | `src/lib/skills/uiConfig.js` `normalizeSection()` | **none** | Consequence, stated precisely: **if §3.3/§3.4 are adopted today, the server will store and round-trip an `owliver:` block byte-for-byte and validate the `pages:` list, but it will not reject an unknown capability, an unknown data source, an unknown period, or a capability that resolves no source.** That validation stays in `owliverConfig.js`. The one known divergence is pinned by a test: `krow-demo/skill-examples/board-invalid-context.md` is refused by the frontend (on a placement/data-source rule) and accepted by the backend. The conformance suite asserts it remains the **only** such case. **This is acceptable for Phase 3.** The browser is the only renderer, so browser-side refusal is sufficient to keep a bad definition off a page. It stops being acceptable the moment the *server* answers an Owliver question — which is §7. ### 6.7 `internal/runtime` — built, tested, and not wired `go-api/internal/runtime/` (loader 237 lines, executor 129, types 103, tests 890) loads a definition by uuid **or** `definition_id`, re-validates it, resolves skill dependencies, refuses draft/archived agents and inactive skills, detects circular dependencies, and hands off to an `AgentExecutor` / `SkillExecutor` boundary. The default is `UnavailableExecutor`, which returns `ErrExecutorUnavailable` — "AI executor is unavailable (deferred to Phase 5)" (`runtime/types.go` `ErrExecutorUnavailable`). `grep -rn "internal/runtime" go-api --include='*.go'` outside the package and its tests returns **nothing**. It is not constructed in `Server.New` (`internal/httpserver/server.go` `Server.New`) and has no route. **This is the designed seam for §7 and §10.3.** Do not build a parallel one. ### 6.8 What is still frontend-only, definitively 1. All `owliver:` and `ui:` block semantics — capabilities, sources, periods, shapes, placements, inheritance, `editable` requiring both halves. 2. Every one of the 25 data-source resolvers (`src/lib/skills/dataResolver.js`, 1,214 lines). 3. `disabledSkills` / `removedSkills` suppression — correctly so. 4. Agent conversation state, history and panel layout (`components/ai-assistant/*`, `localStorage`/`sessionStorage`). 5. Shipped definitions themselves — `krow-demo/src/agents/**`, `src/skills/**`. Migration `000005`'s header states they must stay in Git: putting them in a table would trade `git log`, review and atomic deploy for nothing. --- ## 7. Owliver skill responses — what backend support would actually require **Nothing here is proposed for Phases 1–3.** This section answers "what would it take", so the decision can be made with the cost visible. ### 7.1 The requirement Today, `provider.stream(request)` (`components/ai-assistant/provider.js`) yields **snapshots** — the whole block array so far, not deltas, because a table or a KPI row has no meaningful half-state. `createAssistantProvider()` returns `createHttpProvider` only when `VITE_ASSISTANT_ENDPOINT` is set; it is unset, so `createLocalProvider()` runs and computes deterministic answers from the dashboard's own already-loaded data. For a response to become *backend-supported*, three things must move server-side, and they are separable: | Layer | What it is | Cost | | --- | --- | --- | | **L1 — vocabulary** | The closed tables in §6.6: 10 capabilities, 25 sources, 9 section types, 6 periods, and the placement→context rules | ~600 lines of transcription + a conformance oracle, mirroring what `vocabulary.go` already does for the 18 surfaces | | **L2 — resolvers** | 25 data sources rewritten as SQL against `job_applications`, `job_postings`, `worker_profiles`, `assignments`, `shift_records`, `staff`, `courses`, `user_activity` | the real cost — `dataResolver.js` is 1,214 lines and depends on `lib/workforce.js`, `lib/attendance.js`, `lib/activitySignals.js`, `lib/positionModel.js`, `lib/talentHome.js`, `lib/admin/positionInsights.js` | | **L3 — transport** | one streaming endpoint | small, and specified in §10.3 | ### 7.2 The security argument for doing it `api-audit.md` §7 records that the fact sheet is **deliberately not sent** to the provider: dashboard data is to be read server-side from the caller's own session, so a client cannot ask about records it is not entitled to see, and an agent cannot widen that by being named in the body. That property is **only achievable server-side.** L2 done in Go inherits the tenancy and talent-scope predicates that already live in `internal/domain/policy.go` and `internal/repo/repo.go` — the same predicates every list endpoint uses. Done any other way it has to be re-derived. ### 7.3 The one endpoint, if it is built Specified in full in §10.3. **No other route is required** — a capability resolves to a section, a section resolves to a source, and the answer streams back as blocks. There is no second shape for "the chat version", by design (`api-audit.md` §6.1). ### 7.4 Order L1 and L2 are useless without L3, and L3 without L2 answers nothing. But L2 can be built **one source at a time** behind a capability list the server advertises: a capability whose source has no server resolver is simply not offered, which is exactly the rule `owliverConfig.js` already applies (`api-audit.md` §6.1: "a capability that resolves no source is dropped, with a named error"). That makes this incremental rather than all-or-nothing, and it is why §12 Phase 4 is a phase rather than a single task. --- ## 8. Flow-chart data ### 8.1 Where it comes from today — traced A `flow` is one of the 9 `SECTION_TYPES` and one of the 10 `OWLIVER_CAPABILITIES`. Its data is produced by `resolveSkillData(section, context)` (`src/lib/skills/dataResolver.js:1196`), which dispatches on `section.source` into the `RESOLVERS` table. `context` is assembled in exactly two places, and both assemble it from **live query hooks over registered endpoints**: - `src/components/skills/SkillSurface.jsx:81-119` — `useApplications`, `useJobPostings`, `useInterviews`, `useCourses`, `useWorkerProfiles`, `useStaff`, `useUserActivity`, `useAssignments`, `useShiftRecords`, `useCurrentUser`, plus `trainingPaths` derived from courses + the profile. - `src/components/ai-assistant/KrowAssistant.jsx:300-343` — the same set for the panel. `ResponseBlocks.jsx:479` re-resolves the same section live for a chat answer. ### 8.2 Can existing backend resources provide it? **Yes — entirely, and they already do.** Every collection above is a registered list endpoint with a matching default sort and limit: | Context key | Endpoint | Default sort / limit | | --- | --- | --- | | `applications` | `GET /job-applications` | `-ai_score` / 200 | | `positions` | `GET /job-postings` | `-created_date` / 100 | | `interviews` | `GET /ai-interviews` | `-created_date` / 100 | | `courses` | `GET /courses` | `-created_date` / 200 | | `workerProfiles` / `profiles` | `GET /worker-profiles` | `-krow_score` / 500 | | `assignments` | `GET /assignments` | `-created_date` / 500 | | `staff` | `GET /staff` | `-created_date` / 100 | | `activity` | `GET /user-activity` | `-created_date` / 500 | | `shifts` | `GET /shift-records` | `-created_date` / 500 | Periods are computed at read time from `created_date` (`dataResolver.js` `periodRange` (:48), `inPeriod` at `:77-84`) — never stored, never hard-coded. The server already returns `created_date` in the exact millisecond ISO-8601 form the resolvers parse (`domain/resource.go` `Column.SelectExpr` (:115)). ### 8.3 Recommendation **No new endpoint. No backend change.** Flow-chart data is a client-side reading over records the API already serves, and the reading must stay wherever the renderer is. The only backend involvement flow data would ever need is §7 L2 — and that is a *relocation* of `dataResolver.js`, not a new resource. One honest caveat, and it is §9.3's caveat too: a flow over `shift-records` reads at most 500 rows because that is the limit the frontend asks for, and the frontend discards the `meta.truncated` flag the server already sends. Fix that in the frontend. --- ## 9. Dashboard / analytics / activity ### 9.1 What is real | Surface | Values | Source | | --- | --- | --- | | `pages/Overview.jsx:48-57` | active positions, total applications, screened, new, average AI score, hires | `useJobPostings` + `useApplications` — **all real** | | `pages/Overview.jsx:60-61` | top candidates, active postings | same two, sorted/sliced client-side | | `pages/Analytics.jsx:13-16` | funnel, interview analytics, hire metrics | `useApplications`, `useInterviews`, `useStaff`, `useJobPostings` — **all real** | | `pages/UserTracking.jsx:10` | activity feed | `useUserActivity` — **real** | | `pages/TalentPool.jsx:9` | pool, score bands | `useWorkerProfiles` — **real** | | `pages/HiredHistory.jsx:41-42` | hires, tiers, reviews | `useStaff` + `useJobPostings` — **real** | | `lib/activitySignals.js` | anomaly signals, privileged-event share | `user_activity` rows — **real** | | `lib/attendance.js` | attendance, overtime, weekly trend | `shift_records` — **real** | **Nothing on the dashboard is dummy or localStorage-backed.** The only local values are UI state (§2.3). ### 9.2 Are the existing list APIs sufficient? **Yes.** Every figure is a count, a filter, an average or a sort over one of the nine collections. All nine are registered; the frontend's requested limits match the server's declared defaults exactly (`api-audit.md` §3, verified against `resources_gen.go`). **No aggregation endpoint is proposed.** Adding one would duplicate arithmetic that already exists and has to keep existing for the offline/local provider, and would create a second place for "how many were screened" to be defined. ### 9.3 The one real risk — and it is already solvable, in the frontend `writePage` (`internal/httpserver/response.go` `writePage` (:52)) returns `meta: {total, limit, offset, returned, truncated}` on **every** list response, and `truncated` is computed as `total > offset + len(records)`. `krow-demo/src/api/httpClient.js:226` returns `payload.data` and **drops `meta` entirely.** So when an organization exceeds 500 shift records or 200 applications, every dashboard figure silently under-reports and nothing says so. - **WIRE** — surface `meta` through `httpClient.request()` and have the hooks refuse to render a total when `truncated` is true. Frontend only. - **No backend change.** The server already answers the question; the client throws the answer away. `internal/httpserver/response.go` `meta` (:20) records that `truncated` exists precisely so this is fixable without a contract change. ### 9.4 Activity vocabulary — the one backend change Covered as H-1 and A-2 in §3. Restated because it lands here: the two workflow endpoints write `candidate_hired` and `worker_assigned`; every other producer and consumer in the system — 9 frontend event types, `PRIVILEGED_EVENTS`, the seed fixture — uses `hire_candidate` and `assign_employee`. Two string literals in `go-api/internal/service/workflows.go` (`:212`, `:373`). --- ## 10. Real missing backend capabilities Four, exactly as `api-audit.md` §13 concludes. Each is specified here to the level the "do not invent APIs" rule demands: why it is necessary, request, response, authorization, service, repository, DB impact, migration impact. **None of them is required for Phases 1–3.** ### 10.1 LLM / AI inference — NEW capability, route optional **Why.** `src/api/aiEngine.js` implements `integrations.Core.InvokeLLM` in the browser with **no network and no key**: it recognises a workflow by the stable phrase its prompt opens with, reads the structured fields the prompt already carries, and scores deterministically. Ten consumers: `generateJobDescription`, `buildResumeFromText`, `screenCandidate`, `generateInterviewQuestion`, `matchTalentForJob`, `evaluateInterview`, `owliverQuestion`, `buildProfileFromConversation` (`src/lib/krowAi.js`), and `challengeFollowUp`, `evaluateChallenge` (`src/lib/provingGround.js`). There is no route, no key and no config for this anywhere in the backend — `internal/config/config.go` has `AppEnv`, `Log`, `HTTP`, `DB`, `Seed` and nothing else. **Is a route necessary?** Not for its own sake. Two of the ten consumers already have a natural home: - `screenCandidate` → the screening PATCH it already performs. - `evaluateInterview` → the interview creation in §5.2. The rest are interactive authoring aids. **The recommendation is: build the service first, no route.** `internal/llm` with a single `Complete(ctx, req) (Response, error)` boundary and a deterministic `StubProvider` mirroring `aiEngine.js`, wired the same way `runtime.UnavailableExecutor` is — an interface with an honest default. Then each caller that needs it gets it *inside a handler that already exists*. If a general-purpose route is later required it should be **one**, and it should be the assistant endpoint in §10.3, not a family of per-workflow routes. - **Service:** new `go-api/internal/llm/` (`llm.go`, `stub.go`, `anthropic.go`). - **Repository:** none. - **DB:** none. **Migration: none.** - **Config:** `go-api/internal/config/config.go` gains an `LLM` block — provider, model id, API key, timeout, max tokens. The key must be loaded the same way `DB.Password` is (env only; no default; fail loudly). - **Wiring:** `go-api/cmd/api/main.go`, `go-api/internal/httpserver/server.go`. - **Risk to state now:** an LLM call inside a request handler makes that handler's latency unbounded. `HTTPConfig.WriteTimeout` must be revisited, and a per-call `context.WithTimeout` is mandatory. ### 10.2 File storage / upload — NEW, one route genuinely required **Why.** `integrations.Core.UploadFile({file})` returns `{file_url, file_name, file_size}` where `file_url` is a `blob:` URL from `URL.createObjectURL`. It is valid for the rest of the session and **dies on reload**. Consumers: `src/pages/KrowIdentity.jsx` (selfie), `src/components/krow/proving/MediaChallenge.jsx` and `VideoRecorder.jsx` (challenge media). That URL is then **persisted**: into `worker_profiles.selfie_url` and `evidence.media_url`, both `text NOT NULL` columns (`migrations/000001_initial_schema.up.sql`). Every such row already in the database holds a dangling reference. This is the one missing capability that is actively corrupting stored data. **A route is genuinely necessary** because there is no existing endpoint that accepts bytes: every registered handler reads a ≤4 MiB JSON body (`internal/httpserver/api.go:15`). | Field | Specification | | --- | --- | | **Route** | `POST /api/v1/files` | | **Auth** | session required (not in `publicPaths`) | | **Authorization** | any authenticated role. It is the *reference* that carries meaning, and the resources holding references are already scoped. | | **Request** | `multipart/form-data`, one part `file`. Bounded by a new `HTTP.MaxUploadBytes` — **not** `maxBodyBytes`, which must stay 4 MiB for JSON. | | **Response** | `201 {"data": {"file_url": "...", "file_name": "...", "file_size": 12345, "content_type": "image/jpeg", "id": ""}}` — `file_url`, `file_name`, `file_size` verbatim so `aiEngine.UploadFile`'s three call sites need no change beyond swapping the implementation. | | **Errors** | 413 over the size bound; 422 on a rejected content type; the existing envelope. | | **Content types** | an allowlist derived from the three call sites: `image/jpeg`, `image/png`, `image/webp`, `video/mp4`, `video/webm`. Never trust the client's declared type — sniff. | | **Service** | new `go-api/internal/service/files.go` | | **Repository** | new `go-api/internal/repo/files.go` | | **Storage** | an interface in new `go-api/internal/storage/` with a local-filesystem implementation for development and an object-store implementation for deployment. Bytes must **not** go in PostgreSQL. | | **Read path** | `file_url` should be a URL this API can serve or redirect from, so access stays behind the session. A public object-store URL would make every uploaded selfie world-readable — do not do that. | **DB impact — one new table, one new migration.** `migrations/000006_files.up.sql` / `.down.sql` (next free number; depends on `000001` for `organizations` and `users`): ``` files id uuid PRIMARY KEY DEFAULT gen_random_uuid() org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE uploaded_by uuid REFERENCES users(id) ON DELETE SET NULL storage_key text NOT NULL -- opaque; never the original filename file_name text NOT NULL content_type text NOT NULL file_size bigint NOT NULL created_date timestamptz NOT NULL DEFAULT now() CHECK (file_size > 0) CHECK (length(btrim(file_name)) > 0) UNIQUE (storage_key) INDEX files_org_created_idx (org_id, created_date DESC) ``` - **Foreign keys:** `org_id` CASCADE (tenancy, matching every other table); `uploaded_by` SET NULL (attribution survives the author leaving, matching `job_postings.created_by` and `agent_definitions.created_by`). - **No new enum.** `content_type` is text with an application-level allowlist; the vocabulary will grow, and `000005`'s header already argues text+CHECK over `ALTER TYPE ... ADD VALUE` for exactly this reason. - **No FK from `worker_profiles.selfie_url` or `evidence.media_url`.** They are `text` URLs today and every existing row holds a dead `blob:` value; adding a FK would require back-filling data that cannot be recovered. Leave them as URLs. - **Seed:** none. **Rollback:** `DROP TABLE files;` — safe, because nothing references it. Blobs in the object store are **not** removed by the rollback; that is deliberate and must be documented in the down migration, as `000005` documents its own choices. ### 10.3 Assistant streaming — NEW, one route, and only after §7 L2 **Why.** `createHttpProvider({endpoint})` (`src/components/ai-assistant/provider.js`) is written, tested and unused, because `VITE_ASSISTANT_ENDPOINT` is unset. Setting it at a route that does not exist would replace working local answers with `Assistant request failed: 404`. **Do not build this before §7 L2 has at least one real resolver.** The endpoint without resolvers can only proxy an LLM, and the whole security argument for moving Owliver server-side (§7.2) is that the *data* is read from the caller's session. | Field | Specification | | --- | --- | | **Route** | `POST /api/v1/assistant/stream` | | **Auth** | session required | | **Authorization** | any authenticated role. Row visibility comes from the resolvers, which reuse `internal/domain/policy.go` scopes — a talent caller asking for `candidates.pipeline` gets their own rows or nothing. | | **Request** | exactly what `createHttpProvider` already sends: `{"contextId": "admin.positions", "capability": "flow", "question": "...", "agent": {...}, "owliverContext": {...}}`. **The fact sheet is deliberately absent and must stay absent.** | | **Response** | `text/event-stream`. Lines the client already parses: `data: {"block": {...}}`, `data: {"delta": "..."}`, `data: [DONE]`. Non-2xx throws client-side — so an error must be a status code, not an SSE frame. | | **Headers** | `Content-Type: text/event-stream`, `Cache-Control: no-store`, `X-Accel-Buffering: no`. | | **Service** | new `go-api/internal/service/assistant.go`, built **on `internal/runtime`** — `runtime.Engine` already loads a definition, checks eligibility, resolves dependencies and hands to an executor. Replace `UnavailableExecutor` with a real one; do not build a parallel loader. | | **Repository** | reuses `internal/repo/definitions.go` (already has `GetAgentByDefinitionID`/`GetSkillByDefinitionID` with personal-over-organization precedence) and `internal/repo/repo.go` for the data reads. | | **DB impact** | **none for the endpoint itself.** Conversation persistence is explicitly out of scope — `migrations/000005`'s header lists `conversations` as deferred, and the frontend keeps history in `localStorage` under `krow_assistant:history`. Do not add a table until a product requirement asks for cross-device history. | | **Migration** | none. | | **Config** | `HTTP.WriteTimeout` must not apply to a stream — use `http.ResponseController` or a per-route timeout, or a long answer is cut mid-block. | ### 10.4 Web search — NEW, lowest priority, no route of its own **Why.** An agent may declare `webSearch: true` (`internal/definition/agent.go` parses it into `Agent.WebSearch`; migration `000005` stores nothing for it — it lives in the markdown). The frontend is honest about it: an agent with `webSearch` set gets a note saying no search provider is configured in this deployment, rather than an answer that quietly came from nowhere. **No route.** Search is a tool the assistant executor calls, not a resource a client fetches. It belongs behind §10.3 as one more capability of the executor. - **Service:** new `go-api/internal/search/` with a provider interface and a `NotConfigured` default that returns the same honest refusal the UI already shows. - **Config:** provider + key in `go-api/internal/config/config.go`. - **DB:** none. **Migration:** none. ### 10.5 Other gaps found during this audit | # | Gap | Class | Where | | --- | --- | --- | --- | | G-1 | Activity event-type mismatch (`candidate_hired`/`worker_assigned`) | **CHANGE** | `internal/service/workflows.go:212,373` | | G-2 | Assign cannot create the application the frontend requires | **CHANGE** | `internal/service/workflows.go`, `internal/httpserver/workflows.go` | | G-3 | Talent cannot complete an interview (403 on the linked PATCH) | **CHANGE** | §5.2 | | G-4 | `serverSupplies` ignores `Derived.TalentOnly` | **CHANGE** | `internal/service/service.go` `serverSupplies` (:266) | | G-5 | PATCH on a definition silently drops `status` when `markdown` is present | **CHANGE** | `internal/service/definitions.go:241,415` | | G-6 | No optimistic concurrency on agent publish | **CHANGE (optional)** | `internal/repo/definitions.go` `UpdateAgent` (:270) | | G-7 | An operator-created worker profile locks its subject out | **CHANGE (deferred)** | §5.4 TP-1 | | G-8 | `internal/runtime` is complete and unwired | not a defect — the seam for §10.3 | `internal/runtime/` | | G-9 | `meta.truncated` discarded by the client | **WIRE** | `krow-demo/src/api/httpClient.js:226` | | G-10 | Admin console has no role gate | **WIRE** | `krow-demo/src/pages/admin/AdminRoute.jsx` `AdminRoute` (:23) | | G-11 | `README.md:106` says "51 registered routes"; the router registers 53 | doc drift | `README.md` | | G-12 | `docs/api-contract.md` §1 says "Auth: None in v1" | doc drift | `docs/api-contract.md` | --- ## 11. Database impact, per required change The schema is at **`000005`**. The next free number is **`000006`**. Every migration below depends on `000001` for `organizations` and `users`. ### 11.1 Changes requiring NO database work This is most of the plan, and it is the point. | Change | Existing tables touched | New table | New column | FK | Index | Enum | Migration | Seed | Rollback | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | --- | | §3.1 Hire adoption (H-1/H-2/H-3) | `job_applications`, `staff`, `user_activity` | no | no | no | no | no | **none** | no | revert two string literals | | §3.2 Assign adoption + application creation (A-1/A-2) | `assignments`, `job_applications`, `user_activity`, `job_postings` | no | no | no | **no — reuses `job_applications_posting_email_key` and `job_applications_org_email_idx`** | no | **none** | no | revert the handler | | §3.3/§3.4 Definition adoption | `agent_definitions`, `skill_definitions`, `user_preferences` | no | no | no | no | no | **none** — `000005` already created everything | no | data written to the tables would need re-mirroring into `user_preferences.extra`; see 11.4 | | §5.2 Interview link (G-3) | `ai_interviews`, `job_applications` | no | no | **no — `job_applications.interview_id` exists since `000002`** | no | no | **none** | no | revert the handler | | §5.1 `serverSupplies` fix (G-4) | none | no | no | no | no | no | **none** | no | trivial | | §6.4 definition PATCH semantics (G-5) | none | no | no | no | no | no | **none** | no | trivial | | §6.4 publish concurrency (G-6) | `agent_definitions` | no | no | no | no | no | **none** | no | trivial | | §8 Flow data | none | no | no | no | no | no | **none** | no | n/a | | §9 Dashboard | none | no | no | no | no | no | **none** | no | n/a | | §10.1 LLM service | none | no | no | no | no | no | **none** | no | n/a | | §10.3 Assistant stream | reads only | no | no | no | no | no | **none** | no | n/a | | §10.4 Web search | none | no | no | no | no | no | **none** | no | n/a | ### 11.2 `000006_files` — the only migration this plan requires Full shape in §10.2. - **Existing tables:** `organizations`, `users` (referenced only). - **New table:** `files`. - **New columns on existing tables:** none. - **Foreign keys:** `files.org_id → organizations(id) ON DELETE CASCADE`; `files.uploaded_by → users(id) ON DELETE SET NULL`. - **Indexes:** `UNIQUE (storage_key)`; `files_org_created_idx (org_id, created_date DESC)`. No index on `uploaded_by` — attribution only, matching the reasoning already written into `000005` for `created_by`. - **Enums:** none. `content_type` is `text` + an application allowlist. - **Seed:** none. `seed/fixtures/seed.json` is untouched; the seeder (`go-api/internal/seeder/`) needs no change. - **`make gen-resources`:** only if `files` is to become a `domain.Resource`. **It should not be** — it has a non-JSON request body and no list surface. Keep it hand-written, exactly as the definition endpoints are. - **Rollback:** `DROP TABLE files;`. Blobs already written to the object store survive the rollback and must be removed out of band; state that in the `.down` file. ### 11.3 `000007_worker_profile_claim` — conditional, Phase 7 only Only if TP-1 (§5.4) is resolved by *claiming* rather than by widening the read predicate. Widening the predicate needs **no migration at all**, which is why it is the recommended shape. If claiming is chosen: no new table, no new column — the change is a `UPDATE worker_profiles SET user_id = ... WHERE user_id IS NULL AND email = ...` executed at login, plus a partial index `(org_id, email) WHERE user_id IS NULL` to make the lookup cheap. Rollback drops the index; the claimed `user_id` values are not reversible, which is the reason this needs a product decision first. ### 11.4 Rollback consideration for §3.3/§3.4 — the one that is not free Adopting the definition endpoints creates rows in `agent_definitions` and `skill_definitions` that have **no counterpart** in `user_preferences.extra`. Reverting the frontend afterwards would make every definition authored in the meantime invisible. Mitigations, in order of preference: 1. **Dual-write during the cutover window.** The frontend writes both the table and `customSkills`/`customAgents` for one release, reads from the table, then stops writing preferences. No server change; the merge is a client concern. 2. **A one-off backfill.** Read every `user_preferences.extra.customSkills` / `customAgents` entry, POST each to the corresponding endpoint. This is a **script, not a migration** — it must run through the API so that validation, parsing and the projections are applied by the code that owns them. Put it in `scripts/`, beside `oracle.mjs`. 3. Do **not** write a SQL migration that copies jsonb into the tables. It would have to re-implement `internal/definition`'s parser in SQL to fill `definition_id`, `name`, `description`, `status`, `version` and `pages`. `user_preferences.extra` keys **must not be deleted** by the cutover. Leaving the old blob in place costs a few kilobytes on `GET /me` and is the entire rollback plan. --- ## 12. Implementation order Seven phases. Phases 1–3 need **no migration and no new route**. Phases 1, 2 and 5 are largely frontend work against a server that is already correct. ### Phase 1 — Existing API / frontend wiring (no server change) | # | Task | Files | Class | | --- | --- | --- | --- | | 1.1 | Surface `meta` through `request()` and stop dropping `truncated` | `krow-demo/src/api/httpClient.js:226` and the hooks in `src/lib/krowHooks.js` | WIRE | | 1.2 | Role-gate the admin console — `role ∈ {admin, employer}` for operator screens, `admin` for KROW Forge authoring and certification deletion | `krow-demo/src/pages/admin/AdminRoute.jsx` | WIRE | | 1.3 | Delete `useBadges` (`krowHooks.js:537`) and `User: 'users'` (`httpClient.js:96`) — both would 404 | `krow-demo` | WIRE | | 1.4 | Delete `src/api/store.js` (imported by nothing) | `krow-demo` | WIRE | | 1.5 | Fix the two documentation drifts | `README.md:106` (51→53), `docs/api-contract.md` §1 (auth) | doc | | 1.6 | *Optional:* seed the seven `ROLE_CATEGORIES` as rows and delete the constant | `seed/fixtures/seed.json`, `krow-demo/src/lib/roleCategories.js` | seed | **Exit criterion:** no frontend call reaches a 404 or 405; a truncated list is visibly truncated; a non-admin cannot open an admin-only action. ### Phase 2 — Workflow adoption Server first, then frontend, in this order — the frontend cannot adopt Assign until A-1 lands. | # | Task | Files | Class | | --- | --- | --- | --- | | 2.1 | `candidate_hired` → `hire_candidate`; `worker_assigned` → `assign_employee` | `go-api/internal/service/workflows.go:212,373` | CHANGE | | 2.2 | Align the assign `source` default with the column default (`owliver`) | `go-api/internal/service/workflows.go:336` | CHANGE | | 2.3 | Extend `AssignWorker` with an optional `application:` payload; upsert by `(job_posting_id, email)` inside the transaction; link the assignment | `go-api/internal/service/workflows.go` | CHANGE | | 2.4 | Add `{"job-applications", domain.OpCreate}` to the assign requirement list | `go-api/internal/httpserver/workflows.go` `handleAssign` (:127) | CHANGE | | 2.5 | Fix `serverSupplies` to honour `Derived.TalentOnly` (G-4) | `go-api/internal/service/service.go` `serverSupplies` (:266) | CHANGE | | 2.6 | Link the application from `POST /ai-interviews`, transactionally (G-3) | `go-api/internal/service/service.go` or new `internal/service/interviews.go`; reuse `WorkflowService.inTx` | CHANGE | | 2.7 | Replace `useHireCandidate` with one call to `POST /job-applications/{id}/hire`, sending `{role, profile_tier}`; drop the separate `logActivity` | `krow-demo/src/lib/krowHooks.js` `useHireCandidate` (:296) | WIRE | | 2.8 | Replace `useAssignWorkers` with one call to `POST /job-postings/{id}/assignments` | `krow-demo/src/lib/krowHooks.js` `useAssignWorkers` (:406) | WIRE | | 2.9 | Remove the now-redundant `PATCH` from `AIInterviewModal.finishInterview` | `krow-demo/src/components/krow/AIInterviewModal.jsx` `finishInterview` (:155) | WIRE | **Exit criterion:** a hire is one request and one transaction; an assign of *n* workers is one request and one transaction; a talent user can complete an interview; `activity.signals` still recognises `hire_candidate` as privileged. **No migration. No new route.** ### Phase 3 — Agent / Skill migration | # | Task | Files | Class | | --- | --- | --- | --- | | 3.1 | Fix the silent `status` drop on a markdown PATCH (G-5) | `go-api/internal/service/definitions.go:241,415` | CHANGE | | 3.2 | *Optional:* optimistic concurrency on agent publish (G-6) | `go-api/internal/repo/definitions.go` `UpdateAgent` (:270) | CHANGE | | 3.3 | Point `useAgents` at `/agent-definitions` — `save`/`remove`/`publish`/`archive`/`restore`/`duplicate`/`sourceFor` | `krow-demo/src/lib/agents/useAgents.js` | WIRE | | 3.4 | Point `customSkills` readers/writers at `/skill-definitions` — the single writer is `src/lib/skills/customSkills.js`; the readers are `usePageSkills` / `useWorkforcePaths` | `krow-demo/src/lib/skills/{customSkills,usePageSkills}.js` and the 21 consumers | WIRE | | 3.5 | Dual-write for one release, then stop writing `customSkills` / `customAgents` | `krow-demo` | WIRE | | 3.6 | Backfill script: read `user_preferences.extra`, POST each definition **through the API** | new `scripts/backfill-definitions.mjs` | script | | 3.7 | **Leave `disabledSkills` and `removedSkills` in preferences** — migration `000005` says so | — | — | **Exit criterion:** an authored agent or skill survives a sign-in on another device; `GET /me` no longer carries an unbounded definition blob; an organization-visible definition is visible to colleagues and refused to talent authors. **No migration.** Rollback plan in §11.4 — do not delete the preference keys. ### Phase 4 — Owliver backend support Only after Phase 3, because the server must hold the definitions before it can answer from them. Incremental by construction (§7.4). | # | Task | Files | | --- | --- | --- | | 4.1 | Transcribe L1: capabilities (10), data sources (25), section types (9), periods (6), placement→context rules | new `go-api/internal/definition/owliver.go`, `ui.go`; extend `vocabulary.go` | | 4.2 | Extend `scripts/oracle.mjs` to capture `owliverConfig.js` / `uiConfig.js` output; extend `internal/definition/conformance_test.go` | `scripts/oracle.mjs`, `go-api/internal/definition/` | | 4.3 | Stop deferring — validate `owliver:` and `ui:` where L1 covers them; keep `Skill.Deferred` for whatever remains | `go-api/internal/definition/skill.go` `deferredBlocks` (:235) | | 4.4 | L2, one source at a time, reusing `internal/domain/policy.go` scopes and `internal/repo` | new `go-api/internal/service/sources/` | | 4.5 | Advertise only the capabilities whose sources have a server resolver | `go-api/internal/service/assistant.go` | **No migration.** No route until 4.5 has something to answer with. ### Phase 5 — Dashboard / flow data Nothing to do on the server (§8.3, §9.2). This phase exists so the decision is recorded rather than revisited. | # | Task | | --- | --- | | 5.1 | Confirm no aggregation endpoint is added. Every dashboard figure is a count/filter/average over one of nine registered lists. | | 5.2 | Confirm no flow-data endpoint is added. `resolveSkillData` reads the collections `SkillSurface.jsx:81-119` already loads. | | 5.3 | If a figure is wrong, the cause is Phase 1.1 truncation, not a missing endpoint. Check `meta.truncated` first. | ### Phase 6 — AI / file / assistant integrations Ordered by how much damage the absence is doing. | # | Task | Files | New route? | Migration | | --- | --- | --- | :-: | --- | | 6.1 | **File storage** — the only capability currently corrupting stored data (`blob:` URLs in `worker_profiles.selfie_url`, `evidence.media_url`) | new `internal/storage/`, `internal/service/files.go`, `internal/repo/files.go`, `internal/httpserver/files.go`; `internal/config/config.go`; `cmd/api/main.go` | **yes — `POST /api/v1/files`** | **`000006_files`** | | 6.2 | Swap `aiEngine.UploadFile` to call it; the return shape is unchanged | `krow-demo/src/api/aiEngine.js` | no | — | | 6.3 | **LLM service** with a deterministic stub default | new `internal/llm/`; `internal/config/config.go`; per-call `context.WithTimeout` | **no** | none | | 6.4 | Use it inside handlers that already exist — screening, interview evaluation | `internal/service/` | no | none | | 6.5 | **Assistant streaming**, built on `internal/runtime` by replacing `UnavailableExecutor` | new `internal/service/assistant.go`, `internal/httpserver/assistant.go`; `cmd/api/main.go` | **yes — `POST /api/v1/assistant/stream`** | none | | 6.6 | Set `VITE_ASSISTANT_ENDPOINT` — **not before 6.5 ships**, or working local answers become 404s | `krow-demo` deployment config | no | — | | 6.7 | **Web search** as an executor tool, `NotConfigured` by default | new `internal/search/`; `internal/config/config.go` | **no** | none | **Two new routes total for the whole plan** — 53 → 55. Both are justified above: one because no handler accepts bytes, one because the client already speaks a streaming protocol nothing serves. ### Phase 7 — Tests and integration verification | # | Task | Files | | --- | --- | --- | | 7.1 | Assert the route count. Nothing does today — `Endpoints()` is only logged (`cmd/api/main.go:65`). Add a test pinning 52/53 so a route cannot be added or lost silently. | `go-api/internal/httpserver/server_test.go` (new) | | 7.2 | Workflow tests for the Phase 2 changes: assign-creates-application, assign-links-existing, talent completes an interview, event types | `go-api/internal/httpserver/workflows_test.go`, `api_test.go` | | 7.3 | RBAC matrix regression — every (role, resource, op) triple against `policy.go` | `go-api/internal/httpserver/rbac_test.go` (exists, 730 lines — extend) | | 7.4 | Conformance suite stays green through Phase 4 L1 | `go-api/internal/definition/conformance_test.go` | | 7.5 | Definition CRUD: shadow-by-id precedence, visibility immutability, talent refusal, size bound, idempotent delete | `go-api/internal/httpserver/definitions_api_test.go` (exists, 848 lines — extend) | | 7.6 | Upload tests: size bound, content-type sniffing, tenancy on read | new | | 7.7 | Resolve TP-1 (§5.4) — needs the product decision first | `internal/domain/policy.go`, `internal/repo/repo.go`, possibly `000007` | | 7.8 | End-to-end against a seeded database: `make migrate-up && make seed`, then the full hire → assign → interview → evidence loop | `Makefile` targets exist | --- ## 13. Drift found, and where it belongs | Where | Issue | Fix in | | --- | --- | --- | | `README.md:106` | "51 registered routes" — the router registers **53**; the two workflow routes are missing from the tally | this repo, Phase 1.5 | | `docs/api-contract.md` §1 | "Auth: None in v1. Every endpoint is unauthenticated." — sessions, argon2id and cookie middleware have existed since `000004` / `internal/auth/` | this repo, Phase 1.5 | | `docs/api-contract.md` §2 | lists 34 Live + 4 "unreachable today" = the "38 endpoints" figure. It is a table of *entity* rows, not a count of registered routes. | clarify wording, Phase 1.5 | | `krow-demo/src/api/store.js` | 173 lines, imported by nothing | krow-demo, Phase 1.4 | | `krow-demo/src/api/seed.js` | 1,928 lines retained for one export, `DEMO_USER.preferences` | krow-demo — leave; the synchronous accessor needs it | | `krow-demo/src/lib/krowHooks.js:537` | `useBadges`, zero consumers, no route | krow-demo, Phase 1.3 | | `krow-demo/src/api/httpClient.js:96` | `User: 'users'` names a resource that does not exist | krow-demo, Phase 1.3 | | `krow-demo/src/pages/admin/AdminRoute.jsx` `AdminRoute` (:23) | comment promises a Phase-3D role check that was never added | krow-demo, Phase 1.2 | --- ## 14. Backend file index — everything this plan would touch | File | Phase | Why | | --- | --- | --- | | `go-api/internal/service/workflows.go` | 2 | event types (`:212`, `:373`), assign `source` (`:363`), `AssignWorker.application` + upsert | | `go-api/internal/httpserver/workflows.go` | 2 | add `job-applications:Create` to the assign requirements (`:141-145`) | | `go-api/internal/service/service.go` | 2 | `serverSupplies` must honour `Derived.TalentOnly` (`:266-276`); transactional `Create` for `ai-interviews` | | `go-api/internal/service/interviews.go` | 2 | *(new, optional)* holds the interview→application link if it does not belong in `service.go` | | `go-api/internal/service/definitions.go` | 3 | `status`-with-`markdown` PATCH semantics (`:230`, `:415`) | | `go-api/internal/repo/definitions.go` | 3 | *(optional)* version predicate on agent update (`:261-305`) | | `go-api/internal/definition/vocabulary.go` | 4 | extend with the Owliver/UI vocabularies | | `go-api/internal/definition/owliver.go`, `ui.go` | 4 | *(new)* L1 transcription | | `go-api/internal/definition/skill.go` | 4 | stop deferring what L1 now covers (`:237`) | | `go-api/internal/definition/conformance_test.go` | 4 | extend to the new oracle cases | | `scripts/oracle.mjs` | 4 | capture `owliverConfig.js` / `uiConfig.js` output | | `go-api/internal/service/sources/` | 4 | *(new)* L2 resolvers | | `go-api/internal/storage/` | 6 | *(new)* object-store boundary | | `go-api/internal/service/files.go` | 6 | *(new)* | | `go-api/internal/repo/files.go` | 6 | *(new)* | | `go-api/internal/httpserver/files.go` | 6 | *(new)* `POST /api/v1/files` | | `go-api/internal/llm/` | 6 | *(new)* inference boundary + deterministic stub | | `go-api/internal/search/` | 6 | *(new)* search boundary + `NotConfigured` default | | `go-api/internal/service/assistant.go` | 6 | *(new)* built on `internal/runtime` | | `go-api/internal/httpserver/assistant.go` | 6 | *(new)* `POST /api/v1/assistant/stream` | | `go-api/internal/runtime/executor.go` | 6 | replace `UnavailableExecutor` | | `go-api/internal/config/config.go` | 6 | `LLM`, `Storage`, `Search`, `HTTP.MaxUploadBytes` | | `go-api/cmd/api/main.go` | 6 | wire the new services | | `go-api/internal/httpserver/server.go` | 6 | register the two new routes; keep `Endpoints()` honest | | `migrations/000006_files.up.sql` / `.down.sql` | 6 | the only required migration | | `migrations/000007_worker_profile_claim.*` | 7 | conditional — only if TP-1 is resolved by claiming | | `go-api/internal/httpserver/server_test.go` | 7 | *(new)* pin the route count | | `go-api/internal/httpserver/{workflows,api,rbac,definitions_api}_test.go` | 7 | extend | | `README.md`, `docs/api-contract.md` | 1 | drift | --- ## 15. Summary - **53 routes are registered.** 39 are exercised by the frontend, 13 exist and are ignored, 1 (`GET /health`) is public infrastructure. - **Nothing the frontend calls today is missing from the server.** - **12 of the 13 ignored routes should be adopted** — 2 workflow endpoints that make hire and assign transactional, and 10 definition endpoints that are where authored agents and skills were always meant to live. The 13th (`GET /me/preferences`) is correctly unused. - **Phases 1–3 require no migration and no new route.** They require six small server changes (§10.5 G-1…G-6) and a body of frontend wiring. - **Flow-chart data and every dashboard figure are already fully served** by the nine existing list endpoints. No aggregation endpoint is proposed. The one real risk is silent truncation, and the server already reports it — the client discards the report. - **Four capabilities genuinely do not exist:** LLM inference, file storage, assistant streaming, web search. **Two new routes** are proposed for them — `POST /api/v1/files` and `POST /api/v1/assistant/stream` — and **one migration**, `000006_files`. File storage is first, because it is the only one currently writing dead references into `worker_profiles` and `evidence`. - **`internal/runtime` is already built for the assistant** — loader, eligibility, dependency resolution, executor boundary, 890 lines of tests — and wired to nothing. It is the seam. Do not build a second one.