Files
krow_backend/docs/backend-implementation-plan.md
2026-08-25 16:37:05 +05:30

1567 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <user + preferences>}` + `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": "<the definition, verbatim>", "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": <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/<id>.md', raw:
<markdown>}` — 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: <new 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": "<uuid>"}}` — `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.