1567 lines
96 KiB
Markdown
1567 lines
96 KiB
Markdown
# 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.
|