96 KiB
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)):
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.godecodeBody(:203)) caps the body atmaxBodyBytes = 4 MiB(api.go:15) and reads it into an opendomain.Record.decodeInto(api.godecodeInto(: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.gowritePage(:52). - Errors:
{"error": {code, message, details}}—writeError,response.gowriteError(:91). Status mapping instatusFor,response.gostatusFor(: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.gojsonErrors). - Auth: every path outside
publicPaths(auth.gopublicPaths(:289-293):/health,/api/v1/auth/login,/api/v1/auth/logout) requires thekrow_sessioncookie.authenticate(auth.goauthenticate) resolves it, re-reads the user row on every request, revokes on inactive, and putsauthctx.Identity+orgctxon the context. - Tenancy:
org_idis taken from the session user's row, never the request (auth.goauthenticate,internal/repo/repo.goRepo.Insert(:367)). - Authorization:
Server.authorize(api.goServer.authorize(:64)) readsusers.role, consultsdomain.Policy.Allows, and answers 403 before any query runs. Row visibility is a SQL predicate ininternal/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.gohandleLogin): shape validation → two rate limiters (per-email and per-address,internal/httpserver/ratelimit.go) → constant-time credential verify (internal/auth/credentials.go) → session issue →MarkLoggedInbest-effort. - Every credential failure returns the identical 401 (
domain.Unauthenticated()). - The token is never in the body —
auth.gohandleLoginstates this explicitly. - Cookie attributes:
HttpOnly,Path=/,SameSitefromsessionSameSite()(auth.gosessionSameSite—Nonewhen a CORS allowlist is configured, elseLax),Securewhen not development or when cross-site. - Tables:
users,sessions(migration000004),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.gouserColumns(:33) —id, legacy_id, full_name, email, role, account_type, status, created_date, updated_date, pluspreferences. PATCH /memay write onlyfull_nameandaccount_type(updatableUserFields,me.goupdatableUserFields(:54)).rolewas deliberately removed — the comment atme.goupdatableUserFields(:54) records that leaving it in was a live privilege-escalation path. Attempts to write anything inserverOwnedUserFields(me.goserverOwnedUserFields(:69)) are ignored and logged.- Preferences are split:
owliverDefault,compactDensity,emailDigestare columns (preferenceColumns,me.gopreferenceColumns(:26)); everything else lands inuser_preferences.extrajsonb — which is wherecustomSkills,customAgents,disabledSkillsandremovedSkillslive today. PATCH /me/preferencesis a shallow merge with an upsert (me.gohandlePreferencesPatch). 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
ReadOnlycolumn in the body is ignored, not rejected; nullon aNotNullcolumn is rejected;- an enum value outside
Column.Enumis rejected with the permitted list; - on create, every
Requiredcolumn must be present and non-blank unlessserverSuppliessays the session fills it (service.goserverSupplies(: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"}.visibilitydefaults topersonal(definitions.goCreateAgent(:138)). No projection field is accepted from the body —name,description,status,version,pages,definition_idare all parsed out of the markdown (definitions.goCreateAgent(:138)). - Update request:
{"markdown": ...}or{"status": ...}. Note theelse ifatdefinitions.go:241/definitions.go:415: ifmarkdownis present, a siblingstatusin the same PATCH is silently ignored.visibilityis immutable after creation (definitions.goUpdateAgent(:194)). - Delete: idempotent — a non-UUID or an invisible row returns
{"id": <id>}and 200 (definitions.goDeleteAgent(: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.goagentDefinitionColumns(:77) /skillDefinitionColumns(:92)). - List parameters:
visibility,status,definition_id,sort,limit,offset— and nothing else; an unknown parameter is a 400 (definitions.goallowedDefinitionFilters(:17),:44-49). Sortable columns:created_date,updated_date,name,definition_id,status, andversionfor agents. Default-created_date, limit 100. - Row visibility predicate, applied on every read, update and delete
(
repo/definitions.goGetAgent(:182) and siblings):org_id = :org AND (visibility='organization' OR (visibility='personal' AND owner_user_id = :user)). - Validation:
definition.ValidateAgent(internal/definition/agent.goValidateAgent(:489)) anddefinition.ValidateSkill(internal/definition/skill.goValidateSkill(: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.goHire, the already-hired branch); missing name or email is a 422; writes staff, patches the application tohired, and writes auser_activityrow — all in one transaction, all throughrepo.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 structservice.AssignRequest(workflows.goAssignWorker/AssignRequest(:233/:245)). - Batch capped at
maxAssignmentBatch = 200(workflows.go:47); the whole batch is validated beforeBEGIN. - Response
201 {"data": {"assignments": [...], "count": n}}. - Behaviour per worker: insert
assignments; if and only ifapplication_idwas supplied, patch that application toassigned; insert auser_activityrow. Errors are annotated with the batch index (workflows.goannotate(: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):
- computes
tierfromapplication.ai_score(>=80Skilled,>=60Cross-Trained, else Beginner); PATCH /job-applications/{id}{status: 'hired'};POST /staffwith name, email, phone,role: job?.title || job?.role_category || 'Staff',profile_tier: tier, today'shire_date,application_id,job_posting_id,ai_score,status: 'onboarding';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:
POST /assignments;- look for an existing application on
(job_posting_id, lower(email)); if none,POST /job-applicationswith the worker's profile fields andstatus: 'assigned'; if one exists,PATCHit toassigned; 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_idsupplied → patch it toassigned(unchanged);application_idabsent andapplicationsupplied → look the application up by(job_posting_id, email)— the pair is alreadyUNIQUE(job_applications_posting_email_key, migration000001) 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.goserverSupplies(:266)) iteratesPolicy.Derivedand ignoresDerived.TalentOnly. Forjob-applications,emailisRequiredandDerived{TalentOnly: true}. Consequence: an operator who POSTs an application with noemailskips the service's required-field check entirely, reaches SQL, and tripsemail citext NOT NULL.translate(repo/repo.gotranslate(:673)) turns that into a 422"email must not be null", so the status code is right by accident, but the message and thedetailskey differ from every other required-field refusal and the check is one layer too late. Fix: giveserverSuppliesthe caller's role and skip aTalentOnlyderivation for non-talent callers. Same defect applies toevidence.worker_emailand would apply to any futureTalentOnlyrequired column. One file:go-api/internal/service/service.go. No migration. -
AP-2 —
screened_atis written by nobody. Migration000003droppedjob_applications_screened_consistentand 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:
POST /ai-interviews— the transcript, scores and verdict;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 smallgo-api/internal/service/interviews.goholding the special-cased Create for this one resource), andgo-api/internal/httpserver/api.goonly if the handler needs a transaction begun for it. Prefer routing this resource'sCreatethrough the transactional path already built ingo-api/internal/service/workflows.go(WorkflowService.inTx). - Alternative, rejected: adding
talenttojob-applications:Updatewith a column allowlist. Rejected because it puts a second authorization mechanism (per-column) beside the existing per-operation one, whichinternal/httpserver/workflows.gofile 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 |
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:
statusCHECK('draft','published','archived');version integer NOT NULL DEFAULT 1 CHECK (version >= 1); partial indexagent_definitions_published_idx ... WHERE status='published'. - Skills:
statusCHECK('active','inactive'); partial indexskill_definitions_active_idx ... WHERE status='active'. No version. - Both are projections parsed out of the markdown, never accepted from a
body —
service/definitions.goCreateAgent(:138) builds the insert input entirely fromdefinition.ParseAgent/ParseSkilloutput. PATCH {status}is accepted as a shortcut only whenmarkdownis absent — theelse ifatservice/definitions.go:241and:415. Gap D-1 (CHANGE, minor): a PATCH carrying bothmarkdownandstatussilently drops thestatus. 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.goUpdateAgent(:270)) writes unconditionally;useAgents.publishcompares versions in the browser (useAgents.jspublish(:90)). Two tabs publishing concurrently: last write wins. Not a regression from preferences. Optional Phase 3 follow-up: addAND version < :new_versionto the agent update predicate and answer 409 on zero rows — a change torepo/definitions.goonly, 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.Pageskeeps the strings as the author wrote them;Agent.Pagesare canonicalised (skill.goSkill.Pages(:28),agent.goAgent.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, becausenormalizeAgentcoerces andparseSkillrequires a sequence (agent.goasList(:112)).- A skill's
status:is a coercion, not a check — anything that is notinactivereads asactive(skill.goParseSkill(:107)).
Three rules are the backend's own, all listed as BackendOnly on the
Rejection:
MaxMarkdownLength = 65536characters — themarkdown_sizeCHECK, which the editor does not enforce (definition.go:73,skill.goValidateSkill(:279),agent.goValidateAgent(:489)).MaxVersion = 2147483647—agent_definitions.versionis a PostgreSQLinteger; the editor accepts any whole number ≥ 1 (agent.goValidateAgent(:489)).- "A definition with a
ui:block needs an explicitpages:list" (skill.goValidateSkill(:279)) — becauseparseSkillfalls back to theui: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
- All
owliver:andui:block semantics — capabilities, sources, periods, shapes, placements, inheritance,editablerequiring both halves. - Every one of the 25 data-source resolvers
(
src/lib/skills/dataResolver.js, 1,214 lines). disabledSkills/removedSkillssuppression — correctly so.- Agent conversation state, history and panel layout
(
components/ai-assistant/*,localStorage/sessionStorage). - Shipped definitions themselves —
krow-demo/src/agents/**,src/skills/**. Migration000005's header states they must stay in Git: putting them in a table would tradegit 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, plustrainingPathsderived 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
metathroughhttpClient.request()and have the hooks refuse to render a total whentruncatedis true. Frontend only. - No backend change. The server already answers the question; the client
throws the answer away.
internal/httpserver/response.gometa(:20) records thattruncatedexists 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.gogains anLLMblock — provider, model id, API key, timeout, max tokens. The key must be loaded the same wayDB.Passwordis (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.WriteTimeoutmust be revisited, and a per-callcontext.WithTimeoutis 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_idCASCADE (tenancy, matching every other table);uploaded_bySET NULL (attribution survives the author leaving, matchingjob_postings.created_byandagent_definitions.created_by). - No new enum.
content_typeis text with an application-level allowlist; the vocabulary will grow, and000005's header already argues text+CHECK overALTER TYPE ... ADD VALUEfor exactly this reason. - No FK from
worker_profiles.selfie_urlorevidence.media_url. They aretextURLs today and every existing row holds a deadblob: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, as000005documents 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 aNotConfigureddefault 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 onuploaded_by— attribution only, matching the reasoning already written into000005forcreated_by. - Enums: none.
content_typeistext+ an application allowlist. - Seed: none.
seed/fixtures/seed.jsonis untouched; the seeder (go-api/internal/seeder/) needs no change. make gen-resources: only iffilesis to become adomain.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.downfile.
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:
- Dual-write during the cutover window. The frontend writes both the table
and
customSkills/customAgentsfor one release, reads from the table, then stops writing preferences. No server change; the merge is a client concern. - A one-off backfill. Read every
user_preferences.extra.customSkills/customAgentsentry, 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 inscripts/, besideoracle.mjs. - 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 filldefinition_id,name,description,status,versionandpages.
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/filesandPOST /api/v1/assistant/stream— and one migration,000006_files. File storage is first, because it is the only one currently writing dead references intoworker_profilesandevidence. internal/runtimeis 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.