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

96 KiB
Raw Blame History

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.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

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.