aravind changes

This commit is contained in:
2026-08-25 16:37:05 +05:30
parent cadea4bd92
commit b6f8655909
27 changed files with 5058 additions and 163 deletions

View File

@@ -4,13 +4,22 @@ The backend for Krow — a Go API over PostgreSQL, with a Python Owliver service
to follow. The Krow frontend lives in a **separate repository** (`krow-demo`) to follow. The Krow frontend lives in a **separate repository** (`krow-demo`)
and is not vendored, copied or modified here. and is not vendored, copied or modified here.
**Status: Phase 3C — session authentication.** The API serves the endpoints in **Status: authenticated, authorized, multi-tenant.** The API serves the
`docs/api-contract.md` against PostgreSQL, loaded with the frontend's own demo endpoints in `docs/api-contract.md` against PostgreSQL, loaded with the
dataset. Every endpoint except `GET /health` and the two `/api/v1/auth/*` routes frontend's own demo dataset. Every endpoint except `GET /health` and the two
requires a session: sign in with a password, hold an HttpOnly cookie, and the `/api/v1/auth/*` routes requires a session: sign in with a password, hold an
server resolves it to a real user on every request. Authorization — which roles HttpOnly cookie, and the server resolves it to a real user on every request.
may do what — is Phase 3D and is not implemented. No Owliver, no RAG, no Redis, Roles are enforced — a deny-by-default policy table answers 403 before any query
NATS or S3. runs, and organization scope and talent ownership are SQL predicates, so a row
you may not see answers 404. On top of that sits the agent and skill definition
surface: Markdown with YAML frontmatter, parsed and stored, with full CRUD.
Not built: no Owliver, no RAG, no Redis, no object storage. The agent/skill
runtime is an internal boundary with no executor behind it and no HTTP route
into it.
`docs/KROW_BACKEND_COMPLETE_SUMMARY.md` is the long-form technical account —
architecture, history, and a frank list of the current limitations.
## Layout ## Layout
@@ -19,27 +28,39 @@ krow-backend/
├── go-api/ ├── go-api/
│ ├── cmd/ │ ├── cmd/
│ │ ├── api/ the HTTP service entrypoint │ │ ├── api/ the HTTP service entrypoint
│ │ └── seed/ loads the demo dataset │ │ ├── seed/ loads the demo dataset
│ │ └── setpassword/ the only way a password enters the database
│ ├── internal/ │ ├── internal/
│ │ ├── config/ environment loading + validation │ │ ├── config/ environment loading + validation
│ │ ├── db/ pgx pool and the database health check │ │ ├── db/ pgx pool and the database health check
│ │ ├── domain/ resource descriptors (generated from the schema) │ │ ├── domain/ resource descriptors (generated) + the policy table
│ │ ├── repo/ pgx access layer — every statement built here │ │ ├── repo/ pgx access layer — every statement built here
│ │ ├── service/ validation, org scoping, contract semantics │ │ ├── service/ validation, org scoping, contract semantics
│ │ ├── auth/ argon2id passwords, sessions, the user store
│ │ ├── authctx/ the authenticated identity a request carries
│ │ ├── orgctx/ the organization a request runs as │ │ ├── orgctx/ the organization a request runs as
│ │ ├── definition/ the agent/skill Markdown + YAML frontmatter parser
│ │ ├── runtime/ agent/skill load, validate, resolve, dispatch
│ │ ├── httpserver/ router, handlers, /health, graceful shutdown │ │ ├── httpserver/ router, handlers, /health, graceful shutdown
│ │ ├── seeder/ fixture loading + the attendanceSeed.js port │ │ ├── seeder/ fixture loading + the attendanceSeed.js port
│ │ └── testutil/ disposable migrated + seeded test database │ │ └── testutil/ disposable migrated + seeded test database
│ └── go.mod │ └── go.mod
├── migrations/ SQL migrations — the source of truth for the schema ├── migrations/ SQL migrations — the source of truth for the schema
├── seed/fixtures/ seed.json, generated from the frontend repository ├── seed/fixtures/ seed.json, generated from the frontend repository
├── docs/api-contract.md the frozen client contract ├── docs/
│ ├── api-contract.md the frozen client contract
│ └── KROW_BACKEND_COMPLETE_SUMMARY.md the long-form technical account
├── infrastructure/ deployment definitions (empty until later) ├── infrastructure/ deployment definitions (empty until later)
├── scripts/ verify_schema.sql, gen_resources.py ├── scripts/ verify_schema.sql, gen_resources.py, oracle.mjs
├── Makefile developer, migration and seed tasks ├── Makefile developer, migration and seed tasks
└── .env.example └── .env.example
``` ```
`migrations/`, `seed/`, `scripts/` and `infrastructure/` sit beside `go-api/`
rather than inside it because none of them is Go: the migrations are run by the
`golang-migrate` CLI, the generator is Python, the parser oracle is Node, and a
second service (Owliver, Python) is expected to consume the same schema.
## Architecture ## Architecture
``` ```
@@ -82,10 +103,16 @@ make run # http://127.0.0.1:8080/health
## The API ## The API
38 endpoints, specified in `docs/api-contract.md`. Every one exists because a 54 registered routes: 34 entity routes across 14 resources, 10 agent and skill
frontend call site exists — a table in the database is never a reason for an definition routes, 4 `/me` routes, 2 `/auth` routes, 2 workflow routes, the
endpoint. `DELETE /job-postings/{id}` and `POST /shift-records` return 405 Owliver suggestion route and `GET /health`. The entity routes and the suggestion
because nothing in the frontend deletes a posting or creates a shift record. route are specified in `docs/api-contract.md` (§2 and §2A); the definition and
workflow routes are not yet in the contract and are documented in the source and
its tests. Every
entity route exists because a frontend call site exists — a table in the
database is never a reason for an endpoint. `DELETE /job-postings/{id}` and
`POST /shift-records` return 405 because nothing in the frontend deletes a
posting or creates a shift record.
```bash ```bash
curl localhost:8080/api/v1/job-postings curl localhost:8080/api/v1/job-postings
@@ -110,8 +137,19 @@ Set a password before signing in: the seeded user's `password_hash` is NULL unti
curl -b jar localhost:8080/api/v1/me curl -b jar localhost:8080/api/v1/me
curl -b jar -X POST localhost:8080/api/v1/auth/logout curl -b jar -X POST localhost:8080/api/v1/auth/logout
Authorization is **not** implemented. `users.role` is carried on the identity and **Authorization is enforced.** `users.role` — `admin`, `employer` or `talent`,
consulted nowhere: any signed-in user reaches every endpoint. That is Phase 3D. never the self-editable `account_type` — is the only authority. Entity routes
are gated by the deny-by-default policy table in `internal/domain/policy.go`:
a resource with no policy permits nothing to anyone, and `Server.authorize`
answers 403 before any query runs. Row visibility is a SQL predicate rather than
a filter — the organization scope always, plus an ownership clause for talent
callers — so a row outside it is never fetched and answers 404, which does not
distinguish "exists but not yours" from "does not exist".
The ten definition routes are the exception: they are not `domain.Resource`
values, so the descriptor machinery does not reach them and their role checks
are written inline in `internal/service/definitions.go`. Tenancy and ownership
*are* enforced for them, in the repository predicates.
## Seeding ## Seeding
@@ -135,16 +173,30 @@ records created through the API survive a re-seed.
make test # go test ./... make test # go test ./...
``` ```
55 tests. The database-backed ones build a disposable database — dropped, 175 test functions. The database-backed ones build a disposable database —
recreated, migrated and seeded per run, named `krow_backend_autotest_<pid>` so dropped, recreated, migrated and seeded per run, named
concurrent test packages cannot collide. They skip rather than fail when `krow_backend_autotest_<pid>` so concurrent test packages cannot collide. They
PostgreSQL is unreachable. skip rather than fail when PostgreSQL is unreachable.
Seed assertions compare the database against the fixture field-by-field rather Seed assertions compare the database against the fixture field-by-field rather
than against numbers typed into a test. The two ordering guarantees than against numbers typed into a test. The two ordering guarantees
(`NULLS LAST` in both directions, and the `, id` tiebreaker) are covered by tests (`NULLS LAST` in both directions, and the `, id` tiebreaker) are covered by tests
verified to fail when the guarantee is removed. verified to fail when the guarantee is removed.
**Parser conformance.** The Go definition parser must agree with the frontend's
JavaScript one. `internal/definition/conformance_test.go` asserts against
`internal/definition/testdata/oracle.json`, which is not written by hand: it is
captured by running the real frontend module graph through Vite, so the fixture
records what the JS parser actually does rather than what anyone believes it
does. Regenerating it needs a `krow-demo` checkout, and is not part of
`make test`:
```bash
node scripts/oracle.mjs go-api/internal/definition/testdata/oracle.json
```
`scripts/cases.mjs` holds the adversarial corpus that file is captured over.
`GET /health` is public and says only whether traffic should be sent here: `GET /health` is public and says only whether traffic should be sent here:
`{"status": "ok"}` with `200`, `{"status": "degraded"}` with `200` when the `{"status": "ok"}` with `200`, `{"status": "degraded"}` with `200` when the
database is up but unmigrated or left dirty, and `{"status": "unavailable"}` database is up but unmigrated or left dirty, and `{"status": "unavailable"}`

View File

@@ -1840,7 +1840,7 @@ Recorded in `docs/api-contract.md` §12 and still accurate against the current c
| **GCS-compatible object storage** | — | **Future direction.** No reference in the repository; `infrastructure/README.md` names MinIO as a "later" candidate | — | | **GCS-compatible object storage** | — | **Future direction.** No reference in the repository; `infrastructure/README.md` names MinIO as a "later" candidate | — |
| **Docker deployment** | — | **Future direction.** `infrastructure/` contains only a README; `Dockerfile.api` and `Dockerfile.owliver` are listed there as later work | — | | **Docker deployment** | — | **Future direction.** `infrastructure/` contains only a README; `Dockerfile.api` and `Dockerfile.owliver` are listed there as later work | — |
| **Redis** | Shared state for rate limiting across instances | **Future direction.** `ratelimit.go` names Redis or the database as the seam; nothing is wired | Needed before multi-instance deployment for the limiter to mean anything | | **Redis** | Shared state for rate limiting across instances | **Future direction.** `ratelimit.go` names Redis or the database as the seam; nothing is wired | Needed before multi-instance deployment for the limiter to mean anything |
| **NATS is not part of the target architecture** | — | **Future direction / stated rule.** Note the discrepancy: `infrastructure/README.md` currently lists NATS as a later docker-compose candidate. See §17. | Remove NATS from that table if the rule stands | | **NATS is not part of the target architecture** | — | **Future direction / stated rule.** `infrastructure/README.md` previously listed NATS as a later docker-compose candidate; that table has been corrected. See §17.17. | Keep it out |
| **RAG / pgvector** | — | **Future direction.** `pgvector` is not installed in the local database and is named only once, in `infrastructure/README.md` | Introduce when the architecture calls for it | | **RAG / pgvector** | — | **Future direction.** `pgvector` is not installed in the local database and is named only once, in `infrastructure/README.md` | Introduce when the architecture calls for it |
--- ---
@@ -1850,35 +1850,31 @@ Recorded in `docs/api-contract.md` §12 and still accurate against the current c
Everything below was checked against the current repository. Items the request asked Everything below was checked against the current repository. Items the request asked
about that could not be verified are marked as such rather than guessed at. about that could not be verified are marked as such rather than guessed at.
### 17.1 Documentation is behind the code ### 17.1 Documentation is behind the code — resolved
**Issue.** `README.md` describes the repository as being at an earlier stage than the **Issue as recorded.** `README.md` described the repository as being at an earlier
code is. It states "Authorization — which roles may do what — is Phase 3D and is not stage than the code is. It stated "Authorization — which roles may do what — is Phase
implemented", "Authorization is **not** implemented. `users.role` is carried on the 3D and is not implemented", "Authorization is **not** implemented. `users.role` is
identity and consulted nowhere: any signed-in user reaches every endpoint", "38 carried on the identity and consulted nowhere: any signed-in user reaches every
endpoints" and "55 tests". endpoint", "38 endpoints" and "55 tests". The same staleness appeared in two source
comments: the `internal/httpserver/server.go` package documentation ("Authorization is
NOT here"), and `internal/authctx/authctx.go` ("It is NOT consulted anywhere in Phase
3C: authentication only").
**Cause.** Authorization, the definition tables, the CRUD surface and the runtime all **Cause.** Authorization, the definition tables, the CRUD surface and the runtime all
landed after the README was last revised. landed after the README was last revised.
**Impact.** A reader trusting the README would conclude that any signed-in user
reaches every endpoint, which is not what the code does.
**Current behaviour.** `internal/domain/policy.go`, `Server.authorize`, **Current behaviour.** `internal/domain/policy.go`, `Server.authorize`,
`builder.ownership` and `internal/httpserver/rbac_test.go` (730 lines) all exist and `builder.ownership` and `internal/httpserver/rbac_test.go` (730 lines) all exist and
run. 51 routes are registered. 175 test functions run. run. 51 routes are registered. 175 test functions run. `docs/api-contract.md` §9A
`docs/api-contract.md` §9A *is* current and documents the authorization contract *is* current and documents the authorization contract accurately.
accurately.
**Possible future handling.** Revise `README.md` against the code. **Resolution.** `README.md` was revised against the code: the status paragraph, the
layout tree (which was missing `cmd/setpassword`, `internal/auth`, `internal/authctx`,
The same staleness appears in two source comments: `internal/definition` and `internal/runtime`), the route count, the test count and the
authorization section. The `server.go` and `authctx.go` package comments were
- `internal/httpserver/server.go` package documentation: "Authorization is NOT here. corrected to describe the authorization that exists. `internal/orgctx/orgctx.go`,
A signed-in user reaches every endpoint they could reach before". which still framed itself as pre-authentication, was corrected in the same pass.
- `internal/authctx/authctx.go`: "It is NOT consulted anywhere in Phase 3C:
authentication only" — `Identity.Role` is now consulted by `Server.authorize`,
`builder.ownership`, `guardInsert` and `service/definitions.go`.
### 17.2 The definitions endpoints are not in the API contract ### 17.2 The definitions endpoints are not in the API contract
@@ -2000,23 +1996,26 @@ the effective limit multiplies by instance count, resets on restart, and degrade
a global budget behind a reverse proxy. **Possible future handling:** move the a global budget behind a reverse proxy. **Possible future handling:** move the
limiter behind shared state before deploying more than one instance. limiter behind shared state before deploying more than one instance.
### 17.10 The repository has no commits ### 17.10 The repository has almost no history
**Issue.** `git log` reports `fatal: your current branch 'main' does not have any **Issue as recorded.** `git log` reported `fatal: your current branch 'main' does not
commits yet`. Every file is untracked. have any commits yet`, and every file was untracked.
**Impact.** There is no history, no recovery point, no blame, and no record of when **Current behaviour.** The tree is now committed: a single commit on `main`
any of the work described in §2 happened. The chronology in this document was (`7d12ebe`, "first commit") holds the whole repository.
reconstructed from migration headers, package documentation and the contract, not
from version control.
**Note.** This document does not change that; no commit was made. **Remaining impact.** One commit is not history. There is still no blame, no
incremental recovery point, and no record of when any of the work described in §2
happened. The chronology in this document was reconstructed from migration headers,
package documentation and the contract, not from version control, and that remains
the only source for it.
### 17.11 A minor documentation defect in the source ### 17.11 A minor documentation defect in the source — resolved
`internal/httpserver/api.go` — the doc comment describing `decodeBody` sits `internal/httpserver/api.go` — the doc comment describing `decodeBody` sat
immediately above `decodeInto`, so `decodeInto` carries two doc comments and immediately above `decodeInto`, so `decodeInto` carried two doc comments and
`decodeBody` carries none. `decodeBody` carried none. The `decodeBody` comment has been moved to sit above
`decodeBody`.
### 17.12 Badge endpoint mismatch — verified ### 17.12 Badge endpoint mismatch — verified
@@ -2091,8 +2090,9 @@ among the things that do not exist yet.
**Impact.** A reader of `infrastructure/README.md` would take NATS to be planned. **Impact.** A reader of `infrastructure/README.md` would take NATS to be planned.
**Possible future handling.** Amend that table if the rule stands. Nothing was changed **Resolution.** The rule stands, so the table was amended: `infrastructure/README.md`
here. no longer lists NATS as a candidate and says explicitly that it is not part of the
target architecture. `README.md` no longer names it either.
### 17.18 Deliberate contract behaviours that read as defects ### 17.18 Deliberate contract behaviours that read as defects
@@ -2169,8 +2169,8 @@ deliberately and should be done knowingly.
### NATS ### NATS
**NATS is not part of the target architecture.** Note that `infrastructure/README.md` **NATS is not part of the target architecture.** `infrastructure/README.md` no longer
currently lists it as a later candidate; see §17.17. lists it as a candidate; see §17.17.
### Nearest-term work implied by the code itself ### Nearest-term work implied by the code itself
@@ -2281,9 +2281,9 @@ disposable `krow_backend_autotest_<pid>` database per test process.
**Current Known Limitations:** no AI execution and no public runtime endpoint; login **Current Known Limitations:** no AI execution and no public runtime endpoint; login
rate limiting is per-process and in-memory; CORS does not permit credentials; rate limiting is per-process and in-memory; CORS does not permit credentials;
definitions endpoints bypass the policy table; the runtime package is unreachable definitions endpoints bypass the policy table; the runtime package is unreachable
from the running service; `README.md` and two source comments are behind the code; from the running service; the definitions endpoints are absent from the API contract;
the definitions endpoints are absent from the API contract; attendance data is empty attendance data is empty without a rostering source; the repository has a single
without a rostering source; the repository has no commits. commit and so no usable history.
**Current Development Focus:** the most recent work in the tree is the definition **Current Development Focus:** the most recent work in the tree is the definition
system — schema, parser conformance, CRUD — and the runtime boundary that sits on top system — schema, parser conformance, CRUD — and the runtime boundary that sits on top
@@ -2392,13 +2392,12 @@ is how this codebase would stop being trustworthy.
- **The main current limitations** are: no AI execution and no runtime endpoint; - **The main current limitations** are: no AI execution and no runtime endpoint;
login rate limiting that does not survive scale-out; CORS that does not yet permit login rate limiting that does not survive scale-out; CORS that does not yet permit
credentials; ten definition endpoints that sit outside the policy table and outside credentials; ten definition endpoints that sit outside the policy table and outside
the written contract; documentation that is behind the code; and a repository with the written contract; and a repository with a single commit and so no usable
no commits. history.
- **The direction** is real execution behind the existing executor interfaces - **The direction** is real execution behind the existing executor interfaces
(Owliver / an LLM provider abstraction), then retrieval, then tooling, then (Owliver / an LLM provider abstraction), then retrieval, then tooling, then
deployment infrastructure — none of which exists here today. **NATS is not part of deployment infrastructure — none of which exists here today. **NATS is not part of
the target architecture**, and the one place in the repository that still names it the target architecture**, and the documentation no longer implies otherwise.
should be corrected.
- **The rules that must not be broken** are in §21. The two that matter most in daily - **The rules that must not be broken** are in §21. The two that matter most in daily
work: never trust a client-supplied identity or tenant, and never rewrite stored work: never trust a client-supplied identity or tenant, and never rewrite stored
Markdown. Markdown.
@@ -2409,3 +2408,9 @@ is how this codebase would stop being trustworthy.
behaviour above was read from the current checkout or from read-only queries against behaviour above was read from the current checkout or from read-only queries against
the local development database. Nothing in the repository was modified to produce the local development database. Nothing in the repository was modified to produce
this document.* this document.*
*Revised on 2026-08-24 by a structure-and-dead-code cleanup pass, which changed no
schema, no migration, no database row and no runtime behaviour. What it did change is
recorded in §17.1, §17.10, §17.11 and §17.17: documentation and source comments that
had fallen behind the code were corrected. The counts above were re-verified against
the checkout and are unchanged.*

View File

@@ -129,6 +129,125 @@ these would leave the shim with methods that 404. See §11 (D6).
--- ---
## 2A. Owliver suggestions
`GET /api/v1/owliver/suggestions?page={surface}&query={typed}`
The one endpoint here that serves no resource. It answers "what could I usefully
ask on this page?" for the Owliver panel, which calls it while the user types —
so it reads no table, opens no transaction, calls no model, and its whole answer
is computed from a static catalogue in `internal/owliver`.
It is not on the public allowlist. Which readings exist depends on the caller's
role, so there is no anonymous answer to give.
### Request
| Parameter | Required | Meaning |
| --- | --- | --- |
| `page` | yes | A surface id from the closed page vocabulary — the same one `internal/definition` validates a definition's `pages:` against. Aliases (`hired`, `forge`, `new-position`) and loose spellings (`Talent Pool`) resolve to the canonical id. |
| `query` | no | What the user has typed so far. |
Any other parameter is `invalid_query`. **Nothing about the caller is accepted
here** — role, organization and user are read from the session, and a request
that names one is refused rather than ignored.
An unknown `page` is `invalid_query`, with the frontend's own wording:
`Unsupported page: {value}. Supported pages: {…}.` An absent, blank or
too-short `query` is **not** an error — there is simply nothing to rank yet.
### Response
```json
{
"data": {
"suggestions": [
{ "text": "Which position has the strongest pipeline?", "intent": "position-strength" },
{ "text": "Show hiring activity as a flow", "intent": "hiring-operations", "capability": "flow" }
]
}
}
```
`data.suggestions` is always an array — `[]` when nothing matches, never `null`
and never an error. There is no `meta`: the list is capped rather than paged.
| Field | Meaning |
| --- | --- |
| `text` | The question, as the user reads it. |
| `intent` | The **frontend capability id** the panel dispatches on, verbatim from the manifests in `src/components/ai-assistant/capabilities/`. Not a backend identifier, and never invented here. |
| `capability` | The section type to draw the answer as, from the closed `OWLIVER_CAPABILITIES` vocabulary. Present only when the query asked for one ("as a flow", "summarize"); absent otherwise. |
Nothing internal is exposed: no matching terms, no resource names, no scores, no
policy detail. `text` is always catalogue wording — no part of the query is
echoed back into a suggestion.
### Selection
Five stages, each of which only ever removes:
```
the page's catalogue → permission → relevance → deduplicate → top 3
```
- **Page.** Intents are keyed by surface, so a suggestion from another page
cannot appear. The same word answers differently per page by construction:
`pipeline` on `positions` is about which role converts, on `candidates` it is
the funnel the applicants are in.
- **Permission.** Each intent declares the resources it reads, and those are
checked against the policy table in §9A — *before* ranking, so a refused
reading is never scored. Two gates, not one: the role must be allowed the
operation, and for an organization-wide reading the role's rows must not be
narrowed. Talent may list job applications; talent may not be offered "which
position has the strongest pipeline?", because their view of that resource is
their own rows. No role list is written down here — see §9A.1.
- **Relevance.** Deterministic keyword ranking over the intent's own terms.
Exact token, then multi-word phrase, then prefix (so a half-typed word still
matches), then extension. Ties break on catalogue order, so the same request
always answers identically. A query naming only a section type ranks the
page's readings; once it names a subject, readings that merely *support* that
shape are dropped rather than used as padding.
- **Deduplicate.** One suggestion per intent id, and no two with the same text.
- **Cap.** Three. Nothing is added to reach three.
A query with fewer than two letters or digits after normalization returns `[]`.
### Normalization
The query is truncated to 200 characters, lower-cased, and reduced to letters,
digits and single spaces — every other character becomes a space rather than
being stripped, so nothing can be glued into a token that was not typed as one.
There is no injection surface to defend: the normalized text is compared against
a fixed table of literals and never reaches SQL, a template, a shell or a log
message. Hostile input is ranked like any other text, and can only ever produce
entries the catalogue already holds.
### Where the catalogue comes from
The frontend owns the vocabulary. Owliver's capabilities are declared per page
context in `src/components/ai-assistant/capabilities/`; `internal/owliver`
transcribes the id and the page, and adds the two things a manifest does not
carry — the words that mean a user is reaching for that reading, and the records
it reads. Same pattern as `internal/definition/vocabulary.go`, and for the same
reason: one vocabulary, named from both ends, with a test on this side that
fails when an id here names no capability there.
**No table, no migration.** The suggestions are derived from definitions that
already exist. Persistence would only be warranted if suggestions became
admin-managed, and nothing in the product asks for that today.
### Not covered
The seven workspace and configuration surfaces — `settings`, `workspace`,
`workspace-agents`, `workspace-skills`, `workspace-skill-configure`,
`skill-development`, `workspace-agent-configure` — are valid pages with no
entries. Their panel answers from the registries rather than from workforce
records, so there is nothing to rank a typed query against. They return `[]`,
which is the honest answer, not a validation error.
---
## 3. Request schemas ## 3. Request schemas
### 3.1 Create — `POST /{resource}` ### 3.1 Create — `POST /{resource}`

File diff suppressed because it is too large Load Diff

View File

@@ -28,10 +28,12 @@ var ErrNoIdentity = errors.New("no authenticated identity in context")
// Identity is who the request is, as resolved from the session row. // Identity is who the request is, as resolved from the session row.
// //
// Role is carried because Phase 3D will need it, and because carrying it now // Role is read once per request by the middleware, out of the user row, so an
// means the middleware reads it once per request instead of every future // authorization check never has to re-query. It is the authorization authority:
// authorization check re-querying the user. It is NOT consulted anywhere in // httpserver.Server.authorize gates operations on it, the repository's ownership
// Phase 3C: authentication only. // predicate narrows a talent caller's rows by it, and service/definitions.go
// checks it on every definition write. AccountType is NOT an authority — a user
// can change their own through PATCH /me.
type Identity struct { type Identity struct {
UserID string UserID string
OrgID string OrgID string

View File

@@ -259,8 +259,13 @@ func (c *Config) validate() error {
// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds // devCORSOrigins are the origins the Vite dev server can occupy. Vite binds
// localhost by default and 127.0.0.1 when asked, and a browser treats those two // localhost by default and 127.0.0.1 when asked, and a browser treats those two
// as different origins, so both are listed. 4173 is `vite preview`. // as different origins, so both are listed. 4173 is `vite preview`.
// 5174 is where Vite lands when 5173 is already taken, which happens whenever a
// second dev server is started; an origin missing from this list is refused at
// the preflight with a bare 403 and no CORS headers, which reads as a server
// fault rather than a misconfigured port.
var devCORSOrigins = []string{ var devCORSOrigins = []string{
"http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:5173", "http://127.0.0.1:5173",
"http://localhost:5174", "http://127.0.0.1:5174",
"http://localhost:4173", "http://127.0.0.1:4173", "http://localhost:4173", "http://127.0.0.1:4173",
} }

View File

@@ -1,6 +1,7 @@
package httpserver package httpserver
import ( import (
"context"
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
@@ -130,7 +131,7 @@ func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc {
writeError(w, s.log, err) writeError(w, s.log, err)
return return
} }
rec, err := svc.Create(r.Context(), ident, body) rec, err := s.create(r.Context(), svc, ident, body)
if err != nil { if err != nil {
writeError(w, s.log, err) writeError(w, s.log, err)
return return
@@ -139,6 +140,25 @@ func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc {
} }
} }
// create inserts through the resource's own service, except where creating a
// record has a consequence in another table.
//
// One resource has one: an AI interview is only half of completing an
// interview, and the application it names has to be linked in the same
// transaction — see internal/service/interviews.go for why the server performs
// that write and the caller may not. Routing it here rather than registering a
// second endpoint keeps POST /api/v1/ai-interviews the only way to write one,
// which is what the client already calls and what the ownership guard already
// covers.
func (s *Server) create(ctx context.Context, svc *service.Service,
ident authctx.Identity, body domain.Record) (domain.Record, error) {
if svc.Resource().Path == service.InterviewsPath {
return s.workflows.CreateInterview(ctx, ident, body)
}
return svc.Create(ctx, ident, body)
}
func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc { func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ident, ok := s.authorize(w, r, svc, domain.OpUpdate) ident, ok := s.authorize(w, r, svc, domain.OpUpdate)
@@ -174,11 +194,6 @@ func (s *Server) handleDelete(svc *service.Service) http.HandlerFunc {
} }
} }
// decodeBody reads a JSON object body.
//
// DisallowUnknownFields is not used — the target is a map, so every field is
// "known" here. Unknown *columns* are rejected in the service, where the
// resource's schema is available to say which those are.
// decodeInto reads a JSON body into a typed struct. // decodeInto reads a JSON body into a typed struct.
// //
// Beside decodeBody rather than replacing it: the resource handlers genuinely // Beside decodeBody rather than replacing it: the resource handlers genuinely
@@ -200,6 +215,11 @@ func decodeInto(r *http.Request, dst any) error {
return nil return nil
} }
// decodeBody reads a JSON object body.
//
// DisallowUnknownFields is not used — the target is a map, so every field is
// "known" here. Unknown *columns* are rejected in the service, where the
// resource's schema is available to say which those are.
func decodeBody(r *http.Request) (domain.Record, error) { func decodeBody(r *http.Request) (domain.Record, error) {
defer func() { _ = r.Body.Close() }() defer func() { _ = r.Body.Close() }()
raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes)) raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes))

View File

@@ -42,27 +42,30 @@ const sessionCookieName = "krow_session"
// that never authenticate anything. // that never authenticate anything.
func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" } func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
// sameSite resolves the configured SameSite mode. // sessionSameSite reports the SameSite mode the session cookie must carry.
// //
// Lax remains the default and the recommendation. "none" exists for the one // Lax is the default and the safer value: it closes the CSRF hole by refusing
// deployment shape that cannot work without it: a frontend on a different // to travel on cross-site subresource requests. That is exactly right when the
// registrable domain from the API. In that case Lax withholds the cookie on // page and the API share an origin, which is the supported deployment.
// every cross-site fetch, so the sign-in succeeds, the Set-Cookie arrives, and
// the next request carries nothing — which reads as a broken session rather
// than as a cookie policy.
// //
// An unrecognised value falls back to Lax rather than to None. config.validate // When the API is configured with a CORS allowlist, the deployment is by
// rejects those before startup, so this is only a belt-and-braces default in // definition the other one: a page on some other origin calls this API
// the safe direction. // directly. A Lax cookie is never sent on those requests, so login would
func (s *Server) sameSite() http.SameSite { // succeed once and every request after it would arrive anonymous. None is the
switch s.cfg.HTTP.CookieSameSite { // only mode a browser will send cross-site, and it requires Secure — which is
case "none": // why an origin allowlist forces Secure on regardless of AppEnv.
func (s *Server) sessionSameSite() http.SameSite {
if len(s.cfg.HTTP.CORSOrigins) > 0 {
return http.SameSiteNoneMode return http.SameSiteNoneMode
case "strict":
return http.SameSiteStrictMode
default:
return http.SameSiteLaxMode
} }
return http.SameSiteLaxMode
}
// crossSiteCookies reports whether the cookie must be marked Secure because it
// has to travel cross-site. SameSite=None without Secure is rejected outright
// by every current browser.
func (s *Server) crossSiteCookies() bool {
return s.sessionSameSite() == http.SameSiteNoneMode
} }
// setSessionCookie writes the raw token to the browser. // setSessionCookie writes the raw token to the browser.
@@ -82,15 +85,13 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string, lifetime
Path: "/", Path: "/",
// HttpOnly: script cannot read it. // HttpOnly: script cannot read it.
HttpOnly: true, HttpOnly: true,
// Lax by default, and Strict/None available through // Lax, not Strict and not None. Strict would drop the cookie on any
// HTTP_COOKIE_SAMESITE. Strict would drop the cookie on any cross-site // cross-site navigation, so following a link into the app would land on
// navigation, so following a link into the app would land on a login // a login page despite a live session. None would require Secure and
// page despite a live session. None sends it on cross-site requests, // would send the cookie on cross-site POSTs, which is the CSRF hole Lax
// which is the CSRF hole Lax exists to close — and is nonetheless the // exists to close.
// only workable value when the frontend is on a different registrable SameSite: s.sessionSameSite(),
// domain. See Server.sameSite. Secure: s.secureCookies() || s.crossSiteCookies(),
SameSite: s.sameSite(),
Secure: s.secureCookies(),
MaxAge: int(lifetime.Seconds()), MaxAge: int(lifetime.Seconds()),
}) })
} }
@@ -107,10 +108,8 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
// Must match the attributes it was set with, SameSite included, or the SameSite: s.sessionSameSite(),
// browser treats this as a different cookie and leaves the original. Secure: s.secureCookies() || s.crossSiteCookies(),
SameSite: s.sameSite(),
Secure: s.secureCookies(),
MaxAge: -1, MaxAge: -1,
}) })
} }

View File

@@ -72,6 +72,12 @@ func cors(origins []string) func(http.Handler) http.Handler {
} }
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
// The frontend sends `credentials: "include"`, and a browser
// discards any response to such a request that does not carry this
// header — preflight included. Safe only because the origin was
// matched exactly above and is echoed back one at a time; "*" is
// never sent, which is the pairing the spec forbids.
w.Header().Set("Access-Control-Allow-Credentials", "true")
// Authentication is a cookie, so the browser will neither send it // Authentication is a cookie, so the browser will neither send it
// nor expose the response without this. It is set for allowlisted // nor expose the response without this. It is set for allowlisted

View File

@@ -0,0 +1,239 @@
package httpserver_test
import (
"context"
"net/http"
"testing"
)
// Completing an AI interview.
//
// The endpoint is unchanged — POST /api/v1/ai-interviews, the one the modal
// already calls — but finishing an interview is two writes, and the second one
// is a write the caller who most often makes the request may not perform. The
// tests below are about that seam: the interview and the link land together,
// they land for a talent user, and nothing about talent's own permissions has
// widened to make it possible.
// interviewCount counts the organization's interview rows.
func interviewCount(t *testing.T, r *rbac) int {
t.Helper()
return countRows(t, r, "ai_interviews")
}
// talentApplication files an application through the API as the talent user, so
// its email is whatever the server derived rather than what a test asked for.
func talentApplication(t *testing.T, r *rbac, who actor) string {
t.Helper()
return mustCreate(t, r, who, "/api/v1/job-applications", map[string]any{
"job_posting_id": r.activePosting,
"applicant_name": who.name,
})
}
func interviewBody(applicationID, postingID string, score any) map[string]any {
body := map[string]any{
"application_id": applicationID,
"job_posting_id": postingID,
"job_title": "Open Role",
"candidate_name": "Candidate",
"messages": []map[string]any{
{"role": "assistant", "content": "Tell me about a difficult shift."},
{"role": "user", "content": "We were two people short and I re-planned the passes."},
},
"verdict": "hire",
"hire_recommendation": "Hire",
"summary": "Composed under pressure.",
}
if score != nil {
body["overall_interview_score"] = score
}
return body
}
/* ── The RBAC break this fixes ──────────────────────────────────────────── */
// A talent user completing their own interview is the whole talent flow, and it
// could not finish: ai-interviews:Create is open to everyone, job-applications:
// Update is operators only, so the interview was written and the application
// never learned about it. Both writes now happen server-side, in one
// transaction, on the row the interview already names.
func TestTalentCompletesTheirOwnInterview(t *testing.T) {
r := newRBAC(t)
app := talentApplication(t, r, r.talA)
got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 88))
if got.code != http.StatusCreated {
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
}
interview := got.body["data"].(map[string]any)
interviewID, _ := interview["id"].(string)
if interviewID == "" {
t.Fatalf("the response carries no interview id: %v", got.body)
}
// The response is still the interview record, unchanged.
if interview["application_id"] != app {
t.Errorf("data.application_id = %v, want %s", interview["application_id"], app)
}
stored := applicationByID(t, r, app)
if stored["status"] != "interview" {
t.Errorf("application.status = %v, want interview — the analytics count "+
"status === 'interview' || interview_id", stored["status"])
}
if stored["interview_id"] != interviewID {
t.Errorf("application.interview_id = %v, want %s", stored["interview_id"], interviewID)
}
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 88 {
t.Errorf("application.ai_score = %v, want the interview's 88", stored["ai_score"])
}
}
// And the permission itself has NOT widened. The server writes that one row on
// the caller's behalf; the caller still cannot patch an application.
func TestCompletingAnInterviewDoesNotWidenApplicationUpdate(t *testing.T) {
r := newRBAC(t)
app := talentApplication(t, r, r.talA)
if got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 70)); got.code != http.StatusCreated {
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
}
if got := r.as(r.talA, "PATCH", "/api/v1/job-applications/"+app,
map[string]any{"status": "hired"}); got.code != http.StatusForbidden {
t.Fatalf("talent PATCH of their own application: got %d, want 403 (%v)", got.code, got.body)
}
}
// An operator's interview links the same way. The atomicity half of the fix is
// not talent-specific: a failure between the two writes left an interview
// attached to an application that did not know about it, whoever ran it.
func TestOperatorCompletingAnInterviewLinksTheApplication(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Operator Candidate", "opcand@example.test")
got := r.as(r.empA, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 64))
if got.code != http.StatusCreated {
t.Fatalf("operator interview: got %d, want 201 (%v)", got.code, got.body)
}
interviewID := got.body["data"].(map[string]any)["id"].(string)
stored := applicationByID(t, r, app)
if stored["status"] != "interview" || stored["interview_id"] != interviewID {
t.Errorf("application = status %v, interview_id %v; want interview / %s",
stored["status"], stored["interview_id"], interviewID)
}
}
/* ── What the link must not do ──────────────────────────────────────────── */
// A body that says nothing about the score must not overwrite the screening
// score with the interview column's default of 0. The field the caller never
// mentioned is not a value they asked to store.
func TestInterviewWithoutAScoreLeavesTheApplicationScore(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Scored", "scored@example.test") // ai_score 77
got := r.as(r.admin, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, nil))
if got.code != http.StatusCreated {
t.Fatalf("interview: got %d, want 201 (%v)", got.code, got.body)
}
stored := applicationByID(t, r, app)
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 77 {
t.Errorf("application.ai_score = %v, want the screening score 77 left alone",
stored["ai_score"])
}
// The status and the link still move — those are what completing an
// interview means.
if stored["status"] != "interview" || stored["interview_id"] == nil {
t.Errorf("application = status %v, interview_id %v; want interview and a link",
stored["status"], stored["interview_id"])
}
}
// Somebody else's application is not a subject a talent user may interview for,
// and the refusal must leave nothing behind — not the interview, and not a
// changed application.
func TestInterviewForAnotherPersonsApplicationWritesNothing(t *testing.T) {
r := newRBAC(t)
app := talentApplication(t, r, r.talA)
before := interviewCount(t, r)
got := r.as(r.talB, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 95))
if got.code != http.StatusNotFound {
t.Fatalf("interview for another person's application: got %d, want 404 (%v)",
got.code, got.body)
}
if after := interviewCount(t, r); after != before {
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
}
stored := applicationByID(t, r, app)
if stored["status"] != "applied" || stored["interview_id"] != nil {
t.Errorf("application = status %v, interview_id %v; want it untouched",
stored["status"], stored["interview_id"])
}
}
// An interview that cannot be written must not move the application either.
// Both writes are in one transaction, so a refusal at the first is the whole
// request rolled back rather than a partial completion.
func TestARefusedInterviewLeavesTheApplicationAlone(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Unfinished", "unfinished@example.test")
before := interviewCount(t, r)
body := interviewBody(app, r.activePosting, 80)
body["verdict"] = "definitely" // outside the interview_verdict enum
got := r.as(r.admin, "POST", "/api/v1/ai-interviews", body)
if got.code == http.StatusCreated {
t.Fatalf("an invalid verdict was accepted: %v", got.body)
}
if after := interviewCount(t, r); after != before {
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
}
stored := applicationByID(t, r, app)
if stored["status"] != "shortlisted" || stored["interview_id"] != nil {
t.Errorf("application = status %v, interview_id %v; want it untouched",
stored["status"], stored["interview_id"])
}
}
// Cross-tenant: the application is in another organization, so it is absent
// rather than forbidden, and no interview is written for it.
func TestInterviewCannotReachAnotherOrganizationsApplication(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Ours", "ours-interview@example.test")
var before int
if err := r.h.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM ai_interviews`).Scan(&before); err != nil {
t.Fatalf("count interviews: %v", err)
}
got := r.as(r.outsider, "POST", "/api/v1/ai-interviews",
interviewBody(app, r.activePosting, 90))
if got.code == http.StatusCreated {
t.Fatalf("an outsider wrote an interview for our application: %v", got.body)
}
var after int
if err := r.h.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM ai_interviews`).Scan(&after); err != nil {
t.Fatalf("count interviews: %v", err)
}
if after != before {
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
}
if stored := applicationByID(t, r, app); stored["interview_id"] != nil {
t.Errorf("application.interview_id = %v, want it untouched", stored["interview_id"])
}
}

View File

@@ -0,0 +1,61 @@
package httpserver
import (
"net/http"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/owliver"
)
// routeOwliver registers the Owliver panel's suggestion endpoint.
//
// GET, and a query string rather than a body, because the request is a read
// with no side effect and the panel issues one per keystroke: a GET is what
// makes it retryable, cancellable and cacheable by anything in front of it.
//
// It is deliberately NOT on the publicPaths allowlist in auth.go. Which
// readings exist depends on the caller's role, so an anonymous suggestion has
// no meaning — and the allowlist's failure mode is a route that refuses
// everyone, which is the direction this should fall in.
func (s *Server) routeOwliver(mux *http.ServeMux) int {
mux.HandleFunc("GET /api/v1/owliver/suggestions", s.handleOwliverSuggestions)
return 1
}
// suggestionsBody is the payload inside the standard data envelope.
//
// An object rather than a bare array, so the response has somewhere to grow — a
// future `truncated` or `context` field would otherwise be a breaking change to
// a client already reading `data` as a list.
type suggestionsBody struct {
Suggestions []owliver.Suggestion `json:"suggestions"`
}
// handleOwliverSuggestions answers what the caller could usefully ask here.
//
// There is no s.authorize call and no policy lookup in this handler, and that
// is the design rather than an omission: this endpoint exposes no resource, so
// there is no operation to gate. Authorization happens per suggestion, inside
// the catalogue, against the same domain.Policy table every other endpoint
// consults — a reading the caller could not perform is never ranked, so it
// cannot be returned. Authentication is upstream, in the middleware.
func (s *Server) handleOwliverSuggestions(w http.ResponseWriter, r *http.Request) {
ident, err := authctx.MustFrom(r.Context())
if err != nil {
// Unreachable: the middleware refuses an unauthenticated request before
// the router sees it. A missing identity here is a wiring bug.
writeError(w, s.log, domain.Internal(err))
return
}
params, err := s.suggestions.ParseParams(r.URL.Query())
if err != nil {
writeError(w, s.log, err)
return
}
writeJSON(w, http.StatusOK, envelope{
Data: suggestionsBody{Suggestions: s.suggestions.Suggest(ident, params)},
})
}

View File

@@ -0,0 +1,315 @@
package httpserver_test
import (
"net/http"
"net/url"
"testing"
)
// GET /api/v1/owliver/suggestions.
//
// The ranking itself is tested in internal/owliver, against no database and no
// server. What is tested here is only what the HTTP boundary adds: the session
// requirement, the query-string contract, the response envelope, and the fact
// that the role deciding which readings exist is the session's rather than
// anything the caller can set.
const suggestPath = "/api/v1/owliver/suggestions"
// suggestURL builds the endpoint's address, escaping as a browser would.
func suggestURL(page, query string) string {
v := url.Values{}
if page != "" {
v.Set("page", page)
}
if query != "" {
v.Set("query", query)
}
return suggestPath + "?" + v.Encode()
}
// suggestions reads the list out of the data envelope, failing the test if the
// response is not shaped as the contract says.
func suggestions(t *testing.T, r response) []map[string]any {
t.Helper()
if r.code != http.StatusOK {
t.Fatalf("status %d, body %v", r.code, r.body)
}
data, ok := r.body["data"].(map[string]any)
if !ok {
t.Fatalf("data is not an object: %v", r.body)
}
raw, ok := data["suggestions"].([]any)
if !ok {
// json null decodes to nil, and an absent key to nothing at all. Both
// break a client that iterates the list without checking.
t.Fatalf("suggestions is not an array (got %#v)", data["suggestions"])
}
out := make([]map[string]any, len(raw))
for i, item := range raw {
entry, ok := item.(map[string]any)
if !ok {
t.Fatalf("suggestion %d is not an object: %#v", i, item)
}
out[i] = entry
}
return out
}
/* ── Authentication ─────────────────────────────────────────────────────── */
// The endpoint is not on the public allowlist. Which readings exist depends on
// who is asking, so an anonymous suggestion has no meaning.
func TestOwliverSuggestionsRequireASession(t *testing.T) {
a := newAPI(t)
got := a.doAnon("GET", suggestURL("positions", "pipeline"), nil)
if got.code != http.StatusUnauthorized {
t.Fatalf("status %d, want 401", got.code)
}
if code := got.codeOrEmpty(); code != "unauthorized" {
t.Fatalf("error code %q, want unauthorized", code)
}
// A refusal must not describe the catalogue it refused to rank.
if _, present := got.body["data"]; present {
t.Fatalf("an unauthenticated refusal carried data: %v", got.body)
}
}
/* ── The query string ───────────────────────────────────────────────────── */
func TestOwliverSuggestionsValidation(t *testing.T) {
a := newAPI(t)
cases := []struct {
name string
path string
want int
}{
{"no page", suggestPath, http.StatusBadRequest},
{"blank page", suggestPath + "?page=%20", http.StatusBadRequest},
{"unknown page", suggestURL("nowhere", "pipeline"), http.StatusBadRequest},
{"a route, not a surface", suggestURL("/admin/positions", "pipeline"), http.StatusBadRequest},
{"unknown parameter", suggestURL("positions", "pipeline") + "&role=admin", http.StatusBadRequest},
// A query is optional: with nothing typed there is nothing to rank, and
// that is an empty list rather than a refusal.
{"no query", suggestURL("positions", ""), http.StatusOK},
{"page alias", suggestURL("hired", "recent"), http.StatusOK},
{"page spelled loosely", suggestURL("Talent Pool", "availability"), http.StatusOK},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := a.do("GET", c.path, nil)
if got.code != c.want {
t.Fatalf("status %d, want %d (body %v)", got.code, c.want, got.body)
}
if c.want == http.StatusBadRequest && got.codeOrEmpty() != "invalid_query" {
t.Fatalf("error code %q, want invalid_query", got.codeOrEmpty())
}
})
}
}
// The mux answers anything but GET, so the endpoint cannot be reached with a
// body that might carry a page, a role or an identity.
func TestOwliverSuggestionsAreReadOnly(t *testing.T) {
a := newAPI(t)
for _, method := range []string{"POST", "PATCH", "DELETE", "PUT"} {
got := a.do(method, suggestURL("positions", "pipeline"), map[string]any{"page": "positions"})
if got.code != http.StatusMethodNotAllowed {
t.Errorf("%s: status %d, want 405", method, got.code)
}
}
}
/* ── The response ───────────────────────────────────────────────────────── */
func TestOwliverSuggestionsResponseShape(t *testing.T) {
a := newAPI(t) // the seeded user is an admin
got := suggestions(t, a.do("GET", suggestURL("positions", "pipeline"), nil))
if len(got) == 0 {
t.Fatal("pipeline on positions returned nothing")
}
if len(got) > 3 {
t.Fatalf("%d suggestions, the cap is 3", len(got))
}
seenIntent, seenText := map[string]bool{}, map[string]bool{}
for i, s := range got {
text, _ := s["text"].(string)
intent, _ := s["intent"].(string)
if text == "" || intent == "" {
t.Fatalf("suggestion %d is incomplete: %v", i, s)
}
if seenIntent[intent] {
t.Fatalf("duplicate intent %q", intent)
}
if seenText[text] {
t.Fatalf("duplicate text %q", text)
}
seenIntent[intent], seenText[text] = true, true
// Nothing internal may ride along: no terms, no resource names, no
// scores, no page keys.
for key := range s {
switch key {
case "text", "intent", "capability":
default:
t.Fatalf("suggestion %d exposes %q: %v", i, key, s)
}
}
}
}
// Asking for a rendering names it in the answer — and only where the reading
// can actually be drawn that way.
func TestOwliverSuggestionsCarryARequestedShape(t *testing.T) {
a := newAPI(t)
got := suggestions(t, a.do("GET", suggestURL("positions", "show hiring activity as a flow"), nil))
if len(got) != 1 {
t.Fatalf("got %d suggestions, want 1: %v", len(got), got)
}
if got[0]["intent"] != "hiring-operations" || got[0]["capability"] != "flow" {
t.Fatalf("got %v", got[0])
}
// With no shape asked for, the field is absent rather than empty.
plain := suggestions(t, a.do("GET", suggestURL("positions", "draft"), nil))
if len(plain) == 0 {
t.Fatal("draft on positions returned nothing")
}
if _, present := plain[0]["capability"]; present {
t.Fatalf("capability was sent for an unshaped query: %v", plain[0])
}
}
// No match is an empty array, not an error and not null.
func TestOwliverSuggestionsEmptyResults(t *testing.T) {
a := newAPI(t)
for _, c := range []struct{ name, query string }{
{"nothing typed", ""},
{"one character", "p"},
{"irrelevant", "sourdough starter recipe"},
} {
t.Run(c.name, func(t *testing.T) {
if got := suggestions(t, a.do("GET", suggestURL("positions", c.query), nil)); len(got) != 0 {
t.Fatalf("got %v, want none", got)
}
})
}
// A real surface the catalogue holds no readings for is the same answer.
if got := suggestions(t, a.do("GET", suggestURL("settings", "owliver"), nil)); len(got) != 0 {
t.Fatalf("settings returned %v", got)
}
}
// The page decides the answer, so the same word must not produce the same list
// everywhere.
func TestOwliverSuggestionsAreScopedToThePage(t *testing.T) {
a := newAPI(t)
read := func(page string) []string {
out := []string{}
for _, s := range suggestions(t, a.do("GET", suggestURL(page, "pipeline"), nil)) {
out = append(out, s["intent"].(string))
}
return out
}
positions, candidates := read("positions"), read("candidates")
if len(positions) == 0 || len(candidates) == 0 {
t.Fatalf("positions=%v candidates=%v", positions, candidates)
}
if len(positions) == len(candidates) {
same := true
for i := range positions {
if positions[i] != candidates[i] {
same = false
break
}
}
if same {
t.Fatalf("both pages answered pipeline with %v", positions)
}
}
}
/* ── Authorization ──────────────────────────────────────────────────────── */
// Who is asking comes from the session, and it decides which readings exist.
//
// Talent may list job applications — but only their own, by a predicate in the
// repository — so the organization-wide readings the operator console offers
// are not theirs, and are absent rather than refused.
func TestOwliverSuggestionsFollowTheCallersRole(t *testing.T) {
r := newRBAC(t)
// A query each page can actually answer, so an empty list means the role
// was filtered rather than that the words matched nothing.
operatorPages := []struct{ page, query string }{
{"control-center", "pipeline attention"},
{"positions", "pipeline attention"},
{"candidates", "candidate score"},
{"hired-history", "recent hires outcomes"},
{"talent-pool", "talent pool availability"},
{"activity", "audit unusual activity"},
}
for _, c := range operatorPages {
for _, act := range []actor{r.admin, r.empA} {
got := suggestions(t, r.as(act, "GET", suggestURL(c.page, c.query), nil))
if len(got) == 0 {
t.Errorf("%s was offered nothing on %s for %q", act.name, c.page, c.query)
}
}
if got := suggestions(t, r.as(r.talA, "GET", suggestURL(c.page, c.query), nil)); len(got) != 0 {
t.Errorf("talent was offered %v on %s", got, c.page)
}
}
// Still 200 with an empty list, never 403: refusing would tell a caller
// which pages hold readings they cannot have.
refused := r.as(r.talA, "GET", suggestURL("positions", "pipeline"), nil)
if refused.code != http.StatusOK {
t.Fatalf("talent got status %d, want 200 with an empty list", refused.code)
}
}
// The role filter is not a blanket refusal for talent: what they may genuinely
// ask — about their own account — is still offered. Without this, the test
// above would pass with the permission check stubbed out to deny everything.
func TestOwliverSuggestionsStillServeTalentTheirOwnReadings(t *testing.T) {
r := newRBAC(t)
got := suggestions(t, r.as(r.talA, "GET", suggestURL("profile", "permission"), nil))
if len(got) == 0 {
t.Fatal("talent was offered nothing about their own account")
}
if got[0]["intent"] != "profile-permissions" {
t.Fatalf("got %v", got[0])
}
}
// A permission-sensitive reading: Hired History reads the staff table, which
// policy.go grants to operators only. Nothing about the request differs — only
// the session behind it.
func TestOwliverSuggestionsHideReadingsARoleCannotPerform(t *testing.T) {
r := newRBAC(t)
const path = suggestPath + "?page=hired-history&query=recent+hires"
for _, act := range []actor{r.admin, r.empA} {
if got := suggestions(t, r.as(act, "GET", path, nil)); len(got) == 0 {
t.Errorf("%s was offered no hiring outcomes", act.name)
}
}
if got := suggestions(t, r.as(r.talA, "GET", path, nil)); len(got) != 0 {
t.Fatalf("talent was offered readings of the staff table: %v", got)
}
}

View File

@@ -314,6 +314,50 @@ func mustCreate(t *testing.T, r *rbac, act actor, path string, body map[string]a
/* ── 3. Mass assignment ─────────────────────────────────────────────────── */ /* ── 3. Mass assignment ─────────────────────────────────────────────────── */
// The other half of a talent-only derivation: what an OPERATOR must supply.
//
// The server fills these columns from the session for a talent caller and for
// nobody else — an operator filing an application or logging evidence is
// writing about somebody who is not them. Treating the column as
// server-supplied for every role let an operator's request past validation and
// into SQL, where it came back as a not-null violation instead of the
// required-field message the contract promises. The two halves have to agree:
// what the repository will derive, and what validation stops asking for.
func TestTalentOnlyDerivedFieldsAreRequiredOfOperators(t *testing.T) {
r := newRBAC(t)
cases := []struct {
name, path, column string
body map[string]any
}{
{"job_applications.email", "/api/v1/job-applications", "email",
map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Nameless"}},
{"evidence.worker_email", "/api/v1/evidence", "worker_email",
map[string]any{"type": "photo_identify"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := r.as(r.admin, "POST", tc.path, tc.body)
if got.code != http.StatusUnprocessableEntity {
t.Fatalf("operator create without %s: got %d, want 422 (%v)",
tc.column, got.code, got.body)
}
details, _ := got.body["error"].(map[string]any)["details"].(map[string]any)
if details[tc.column] != "required" {
t.Errorf("details = %v, want %s: required", details, tc.column)
}
// The same body from a talent caller is complete, because the
// server is about to fill the column in from their session.
if got := r.as(r.talA, "POST", tc.path, tc.body); got.code != http.StatusCreated {
t.Errorf("talent create without %s: got %d, want 201 (%v)",
tc.column, got.code, got.body)
}
})
}
}
// Identity a caller supplies is ignored; identity the server derives wins. // Identity a caller supplies is ignored; identity the server derives wins.
// //
// This is the test that makes the ownership predicates above mean anything. If // This is the test that makes the ownership predicates above mean anything. If

View File

@@ -1,17 +1,24 @@
// Package httpserver holds the HTTP surface. // Package httpserver holds the HTTP surface.
// //
// It serves /health, the sign-in endpoints, the entity endpoints described in // It serves /health, the sign-in endpoints, the entity endpoints described in
// docs/api-contract.md, and the current-user endpoints. // docs/api-contract.md, the current-user endpoints, the agent and skill
// definition endpoints, and the Owliver panel's suggestion endpoint.
// //
// Phase 3C replaced the development identity with real authentication. Every // Authentication replaced the development identity: every request outside the
// request outside the small public allowlist in auth.go must carry a session // small public allowlist in auth.go must carry a session cookie; the middleware
// cookie; the middleware resolves it to a user row and puts that user, and // resolves it to a user row and puts that user, and their organization, on the
// their organization, on the request context. Nothing downstream changed — // request context. Nothing downstream changed — every service and repository
// every service and repository already took the organization as a parameter, // already took the organization as a parameter, which is what devOrgMiddleware
// which is what devOrgMiddleware existed to make true. // existed to make true.
// //
// Authorization is NOT here. A signed-in user reaches every endpoint they could // Authorization is here, in Server.authorize: it reads the role off the
// reach before; deciding which roles may do what is Phase 3D. // authenticated identity, consults the deny-by-default policy table in
// internal/domain/policy.go, and answers 403 before any query runs. Row
// visibility — organization scope, and ownership for talent callers — is a SQL
// predicate in internal/repo instead, so an invisible row answers 404 rather
// than 403. The definition endpoints are the exception: they are not
// domain.Resource values, so their role checks are written inline in
// internal/service/definitions.go rather than in the policy table.
package httpserver package httpserver
import ( import (
@@ -38,6 +45,7 @@ type Server struct {
api *service.Registry api *service.Registry
definitions *service.DefinitionsService definitions *service.DefinitionsService
workflows *service.WorkflowService workflows *service.WorkflowService
suggestions *service.SuggestionsService
log *slog.Logger log *slog.Logger
http *http.Server http *http.Server
started time.Time started time.Time
@@ -126,6 +134,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
api: service.NewRegistry(database.Pool), api: service.NewRegistry(database.Pool),
definitions: service.NewDefinitions(database.Pool), definitions: service.NewDefinitions(database.Pool),
workflows: service.NewWorkflows(database.Pool).WithClock(o.now), workflows: service.NewWorkflows(database.Pool).WithClock(o.now),
suggestions: service.NewSuggestions(),
started: o.now(), started: o.now(),
sessions: sessions, sessions: sessions,
users: users, users: users,
@@ -138,7 +147,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /health", s.handleHealth)
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) + s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) +
s.routeDefinitions(mux) + s.routeWorkflows(mux) s.routeDefinitions(mux) + s.routeWorkflows(mux) + s.routeOwliver(mux)
handler := jsonErrors(mux) handler := jsonErrors(mux)
// Authentication sits where devOrgMiddleware used to, so every route below // Authentication sits where devOrgMiddleware used to, so every route below

View File

@@ -128,6 +128,11 @@ func (s *Server) handleAssign(w http.ResponseWriter, r *http.Request) {
ident, ok := s.authorizeAll(w, r, ident, ok := s.authorizeAll(w, r,
requirement{"assignments", domain.OpCreate}, requirement{"assignments", domain.OpCreate},
requirement{"job-applications", domain.OpUpdate}, requirement{"job-applications", domain.OpUpdate},
// The workflow may now FILE an application as well as patch one, for a
// worker placed on a posting they never applied to. A write the handler
// performs has to appear in the list it is authorized against, even
// when — as here — the resulting permission set is unchanged.
requirement{"job-applications", domain.OpCreate},
requirement{"user-activity", domain.OpCreate}, requirement{"user-activity", domain.OpCreate},
) )
if !ok { if !ok {

View File

@@ -329,3 +329,326 @@ func TestWorkflowEndpointsRequireASession(t *testing.T) {
} }
} }
} }
/* ── Activity vocabulary ────────────────────────────────────────────────── */
// activityTypes returns the event types written about one worker, newest first.
//
// Read straight from the table rather than through GET /user-activity so the
// assertion is about what was STORED. The frontend's anomaly detection reads
// these strings — PRIVILEGED_EVENTS is ['hire_candidate', 'create_position'] —
// and a value the vocabulary does not contain is not a different label, it is
// an event that silently stops counting.
func activityTypes(t *testing.T, r *rbac, workerEmail string) []string {
t.Helper()
rows, err := r.h.Pool.Query(context.Background(),
`SELECT event_type FROM user_activity
WHERE org_id = $1::uuid AND worker_email = $2::citext
ORDER BY created_date DESC, id DESC`, r.orgID, workerEmail)
if err != nil {
t.Fatalf("read user_activity: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatalf("scan user_activity: %v", err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
t.Fatalf("read user_activity: %v", err)
}
return out
}
func TestHireWritesTheFrontendsActivityEvent(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Evented", "evented@example.test")
if got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire",
map[string]any{}); got.code != http.StatusCreated {
t.Fatalf("hire: got %d, want 201 (%v)", got.code, got.body)
}
events := activityTypes(t, r, "evented@example.test")
if len(events) != 1 || events[0] != "hire_candidate" {
t.Errorf("activity = %v, want exactly [hire_candidate] — the vocabulary "+
"activitySignals.js reads, and the one the seed fixture uses", events)
}
}
func TestAssignWritesTheFrontendsActivityEvent(t *testing.T) {
r := newRBAC(t)
if got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "evented-assign@example.test", "worker_name": "Evented",
"starts_at": "2026-09-01T09:00:00Z"},
}}); got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
events := activityTypes(t, r, "evented-assign@example.test")
if len(events) != 1 || events[0] != "assign_employee" {
t.Errorf("activity = %v, want exactly [assign_employee]", events)
}
}
/* ── Assign: source ─────────────────────────────────────────────────────── */
// An unspecified source must mean what the column says it means. The default in
// 000001 is `owliver` and the frontend sends `owliver`; substituting `manual`
// made a row written through this endpoint disagree with a row written through
// POST /assignments about where the same action came from.
func TestAssignDefaultsSourceToTheColumnDefault(t *testing.T) {
r := newRBAC(t)
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "default-source@example.test", "starts_at": "2026-09-01T09:00:00Z"},
{"worker_email": "explicit-source@example.test", "starts_at": "2026-09-01T09:00:00Z",
"source": "manual"},
}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
created := assignmentsOf(t, got)
if created[0]["source"] != "owliver" {
t.Errorf("source = %v, want owliver", created[0]["source"])
}
// An explicit value is still the caller's.
if created[1]["source"] != "manual" {
t.Errorf("source = %v, want the supplied manual", created[1]["source"])
}
}
// assignmentsOf reads the assignment records out of an assign response.
func assignmentsOf(t *testing.T, got response) []map[string]any {
t.Helper()
data, _ := got.body["data"].(map[string]any)
raw, _ := data["assignments"].([]any)
if raw == nil {
t.Fatalf("response carries no assignments: %v", got.body)
}
out := make([]map[string]any, 0, len(raw))
for _, rec := range raw {
out = append(out, rec.(map[string]any))
}
return out
}
// applicationByID reads one application as an operator, or fails.
func applicationByID(t *testing.T, r *rbac, id string) map[string]any {
t.Helper()
list := r.as(r.admin, "GET", "/api/v1/job-applications?limit=500", nil)
if list.code != http.StatusOK {
t.Fatalf("list applications: %d (%v)", list.code, list.body)
}
for _, raw := range list.body["data"].([]any) {
rec := raw.(map[string]any)
if rec["id"] == id {
return rec
}
}
t.Fatalf("application %s not found", id)
return nil
}
/* ── Assign: the application a worker does not have yet ─────────────────── */
// The behaviour the frontend had and the endpoint did not.
//
// An application is what puts a person in the pipeline for a role: the
// candidate record is addressed by it and an interview takes one as its
// subject. A worker assigned from the talent pool has none, so the endpoint has
// to file one — in the same transaction as the assignment, which is the half
// the frontend could not do.
func TestAssignCreatesTheApplicationItNeeds(t *testing.T) {
r := newRBAC(t)
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{{
"worker_email": "from-pool@example.test",
"worker_name": "Pool Worker",
"starts_at": "2026-09-01T09:00:00Z",
"match_score": 88,
"application": map[string]any{
"job_title": "Open Role",
"phone": "555-0100",
"years_experience": 4,
"skills": []string{"service", "bar"},
"professional_summary": "Placed from the talent pool.",
"ai_score": 88,
},
}}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore+1 {
t.Fatalf("job_applications: %d -> %d, want exactly one more", appsBefore, after)
}
assignment := assignmentsOf(t, got)[0]
linked, _ := assignment["application_id"].(string)
if linked == "" {
t.Fatal("the assignment was not linked to the application that was created for it")
}
app := applicationByID(t, r, linked)
if app["status"] != "assigned" {
t.Errorf("application.status = %v, want assigned", app["status"])
}
if app["email"] != "from-pool@example.test" {
t.Errorf("application.email = %v, want the worker's email", app["email"])
}
if app["job_posting_id"] != r.activePosting {
t.Errorf("application.job_posting_id = %v, want the posting being assigned to", app["job_posting_id"])
}
// applicant_name falls back to the worker's name rather than being blank —
// the column has a not-blank check.
if app["applicant_name"] != "Pool Worker" {
t.Errorf("application.applicant_name = %v, want the worker's name", app["applicant_name"])
}
if app["phone"] != "555-0100" {
t.Errorf("application.phone = %v, want the supplied phone", app["phone"])
}
if score, ok := app["ai_score"].(float64); !ok || int(score) != 88 {
t.Errorf("application.ai_score = %v, want 88", app["ai_score"])
}
// The audit entry names the application, so the feed can open it.
var activityApp *string
if err := r.h.Pool.QueryRow(context.Background(),
`SELECT application_id::text FROM user_activity
WHERE org_id = $1::uuid AND worker_email = $2::citext`,
r.orgID, "from-pool@example.test").Scan(&activityApp); err != nil {
t.Fatalf("read the activity entry: %v", err)
}
if activityApp == nil || *activityApp != linked {
t.Errorf("activity.application_id = %v, want %s", activityApp, linked)
}
}
// (job_posting_id, email) is UNIQUE, so the second assign of the same person to
// the same posting must find the application rather than try to file another —
// and the comparison is case-insensitive, because the column is citext and the
// frontend's own lookup lowercased both sides.
func TestAssignLinksAnExistingApplicationInsteadOfDuplicating(t *testing.T) {
r := newRBAC(t)
existing := applicationFor(t, r, r.activePosting, "Already Applied", "Already.Applied@example.test")
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{{
"worker_email": "already.applied@example.test",
"worker_name": "Already Applied",
"starts_at": "2026-09-01T09:00:00Z",
"application": map[string]any{"job_title": "Open Role"},
}}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d, want no new row for a person who already applied",
appsBefore, after)
}
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != existing {
t.Errorf("assignment.application_id = %v, want the existing application %s", linked, existing)
}
if app := applicationByID(t, r, existing); app["status"] != "assigned" {
t.Errorf("application.status = %v, want assigned", app["status"])
}
}
// An id the caller already has still wins over the payload: it is a decision
// they have made, and honouring the payload instead could file a second
// application for the same placement.
func TestAssignPrefersTheSuppliedApplicationID(t *testing.T) {
r := newRBAC(t)
app := applicationFor(t, r, r.activePosting, "Named", "named@example.test")
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{{
"worker_email": "named@example.test",
"worker_name": "Named",
"starts_at": "2026-09-01T09:00:00Z",
"application_id": app,
"application": map[string]any{"applicant_name": "Ignored"},
}}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d, want no new row", appsBefore, after)
}
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != app {
t.Errorf("assignment.application_id = %v, want %s", linked, app)
}
if stored := applicationByID(t, r, app); stored["applicant_name"] != "Named" {
t.Errorf("applicant_name = %v — the payload overwrote a named application",
stored["applicant_name"])
}
}
// No payload, no application. A worker placed straight from the workforce is
// legitimate, and one must not be invented for them.
func TestAssignWithoutAnApplicationPayloadLinksNothing(t *testing.T) {
r := newRBAC(t)
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "unattached@example.test", "worker_name": "Unattached",
"starts_at": "2026-09-01T09:00:00Z"},
}})
if got.code != http.StatusCreated {
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
}
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d, want no application invented", appsBefore, after)
}
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != nil {
t.Errorf("assignment.application_id = %v, want null", linked)
}
}
// The application payload is validated exactly as POST /job-applications would
// validate it, and the batch element that produced the complaint is named.
func TestAssignValidatesTheApplicationPayload(t *testing.T) {
r := newRBAC(t)
assignBefore := countRows(t, r, "assignments")
appsBefore := countRows(t, r, "job_applications")
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
map[string]any{"workers": []map[string]any{
{"worker_email": "good@example.test", "worker_name": "Good",
"starts_at": "2026-09-01T09:00:00Z",
"application": map[string]any{"job_title": "Open Role"}},
{"worker_email": "bad@example.test", "worker_name": "Bad",
"starts_at": "2026-09-01T09:00:00Z",
"application": map[string]any{"english_level": "telepathic"}},
}})
if got.code != http.StatusUnprocessableEntity {
t.Fatalf("assign with an invalid application: got %d, want 422 (%v)", got.code, got.body)
}
details, _ := got.body["error"].(map[string]any)["details"].(map[string]any)
if details["workers[1].english_level"] == nil {
t.Errorf("details = %v, want the failure attributed to workers[1]", details)
}
// And the first worker — whose application WAS filed before the second
// failed — must be gone with it.
if after := countRows(t, r, "job_applications"); after != appsBefore {
t.Errorf("job_applications: %d -> %d — a rejected batch left an application behind",
appsBefore, after)
}
if after := countRows(t, r, "assignments"); after != assignBefore {
t.Errorf("assignments: %d -> %d — a rejected batch left an assignment behind",
assignBefore, after)
}
}

View File

@@ -1,14 +1,17 @@
// Package orgctx carries the organization a request operates on. // Package orgctx carries the organization a request operates on.
// //
// Phase 2C has no authentication, so there is nothing to derive a tenant from. // It predates authentication. Before there was a session to derive a tenant
// Rather than defaulting org_id deep inside the SQL — where it would have to be // from, org_id was put on the request context by one middleware rather than
// unpicked from fourteen repositories once auth arrives — the value is put on // defaulted deep inside the SQL, so that it would not have to be unpicked from
// the request context by one middleware and threaded explicitly through the // fourteen repositories later. That handover has since happened: the
// service and repository boundaries. // authenticate middleware in httpserver reads the organization off the user's
// row, and authctx.Identity.OrgID is what the service and repository layers
// actually read.
// //
// When authentication lands, DevMiddleware is replaced by one that reads the // What is still load-bearing here is DevOrgSlug and DevOrgName, which name the
// organization off the authenticated session. Nothing below this package // organization the seeder creates. With and From remain the read/write pair for
// changes: every caller already takes an org id as a parameter. // the context key the middleware still sets; retiring them in favour of
// authctx.Identity.OrgID alone is a deliberate change, not a tidy-up.
package orgctx package orgctx
import ( import (

View File

@@ -0,0 +1,625 @@
// Package owliver answers "what could I usefully ask here?".
//
// It is the suggestion side of the Owliver panel and nothing else. It does not
// answer questions, does not reach a database, does not call a model, and holds
// no state: a request names a page and what the user has typed so far, and this
// package ranks a static catalogue against it. That is deliberate — the panel
// calls the endpoint on every keystroke, so the work per call has to be a few
// string comparisons over a table built once at process start.
//
// WHERE THE CATALOGUE COMES FROM. Owliver's capabilities are declared in the
// frontend, one manifest per page context, in
// src/components/ai-assistant/capabilities/. Each entry there is an id, a label
// and the function that answers it. This file transcribes the id and the page
// it is offered on, and adds the two things a manifest does not carry because
// the browser never needed them: the words that mean a user is reaching for
// that reading, and the records it reads.
//
// Transcription rather than a second registry, on the pattern already set by
// internal/definition/vocabulary.go: the frontend owns the vocabulary, this
// side names keys from it, and TestIntentIDsAreFrontendCapabilities keeps the
// two honest. An Intent.ID that no manifest declares is a bug here, not a new
// capability — the id in a response is the id the panel dispatches on.
//
// WHAT MAKES A SUGGESTION SAFE TO SHOW. Offering someone a question is a claim
// that they could ask it. Reads is that claim written down: the resources the
// capability actually reads, checked against the one authorization table in
// internal/domain/policy.go. No role list is repeated here, and no rule is
// re-derived — a capability that reads staff is unavailable to talent because
// policy.go says talent may not list staff, and for no other reason.
package owliver
import "github.com/krow/krow-backend/go-api/internal/domain"
// Need is one record set a capability reads, and how much of it.
//
// OrgWide is the half a role check alone cannot express. Every role may list
// job applications; a talent caller sees only their own, because the policy
// attaches a row predicate rather than a refusal. So "which position has the
// strongest pipeline?" is not forbidden to talent — it is unanswerable for
// them, and offering it would promise a reading across records they will never
// be shown. OrgWide asks policy.ScopeFor for exactly that: is this caller's
// view of this resource narrowed, or is it the whole organization?
type Need struct {
Resource string // domain resource path, e.g. "job-applications"
Op domain.Op
OrgWide bool
}
// permitted reports whether a role may perform this reading.
//
// Three gates, all sourced from the descriptors: the API must expose the
// operation at all, the policy must allow the role, and — for an org-wide
// reading — the policy must not narrow the role's rows.
func (n Need) permitted(role domain.Role) bool {
res, ok := domain.ResourceByPath[n.Resource]
if !ok || !res.Supports(n.Op) {
return false
}
if !res.Policy.Allows(n.Op, role) {
return false
}
if n.OrgWide && res.Policy.ScopeFor(role).Kind != domain.ScopeNone {
return false
}
return true
}
// Intent is one reading Owliver can offer on one page.
type Intent struct {
// ID is the frontend capability id, verbatim. It is what the panel
// dispatches on, which is why it is not invented here.
ID string
// Text is the suggestion as the user reads it: a question in their words,
// not a label. Unique within a page.
Text string
// Subject names what the reading is about — "hiring activity", "the
// candidate pipeline" — and exists so a shaped variant can be phrased
// ("Summarize hiring activity", "Show hiring activity as a flow") without
// writing every combination out. An intent with no Subject is offered only
// as its Text.
Subject string
// Shapes are the section types this reading can be drawn as, named from the
// closed OWLIVER_CAPABILITIES vocabulary in src/lib/skills/surfaces.js.
// Empty means prose only. `summary` is never listed: it has no component,
// so it applies to anything with a Subject.
Shapes []string
// Terms are the words that mean a user is reaching for this reading. About
// the subject, never about the shape — "as a flow" belongs to the shape
// table, and putting it here would offer every page's intents to anyone who
// typed "chart".
Terms []string
// Reads is what the capability actually reads. Empty means it reads nothing
// but the caller's own account, and is therefore available to anyone signed
// in.
Reads []Need
}
// permitted reports whether a role may be offered this intent. Every reading
// must be permitted: a suggestion that is half-answerable is not answerable.
func (i Intent) permitted(role domain.Role) bool {
for _, n := range i.Reads {
if !n.permitted(role) {
return false
}
}
return true
}
/* ── The readings each resource stands for ──────────────────────────────── */
//
// Named rather than repeated so that "this capability reads the organization's
// applications" is written once and reads the same everywhere it appears.
var (
postings = Need{Resource: "job-postings", Op: domain.OpList, OrgWide: true}
applications = Need{Resource: "job-applications", Op: domain.OpList, OrgWide: true}
interviews = Need{Resource: "ai-interviews", Op: domain.OpList, OrgWide: true}
profiles = Need{Resource: "worker-profiles", Op: domain.OpList, OrgWide: true}
staff = Need{Resource: "staff", Op: domain.OpList, OrgWide: true}
orgActivity = Need{Resource: "user-activity", Op: domain.OpList, OrgWide: true}
courses = Need{Resource: "courses", Op: domain.OpList, OrgWide: true}
certs = Need{Resource: "certifications", Op: domain.OpList, OrgWide: true}
evidence = Need{Resource: "evidence", Op: domain.OpList, OrgWide: true}
// The caller's own audit trail rather than the organization's — the Profile
// page reads what *you* did, which every role may do for themselves.
ownActivity = Need{Resource: "user-activity", Op: domain.OpList}
)
/* ── The catalogue ──────────────────────────────────────────────────────── */
// catalogue is every intent, keyed by canonical page id.
//
// Keys are canonical SKILL_SURFACES ids — the same vocabulary
// internal/definition validates a definition's `pages:` against. A page that is
// a real surface but has no entry here is not an error: it answers with an
// empty list, which is the honest reply for a screen holding no readings.
//
// The seven workspace and configuration surfaces are deliberately absent.
// Their manifests explain the screen rather than read workforce records — they
// have no data behind them to rank a typed query against, and the panel there
// answers from the registries directly.
//
// Declaration order is the tie-break when two intents score equally, so the
// order within a page is the order the frontend manifest lists them in.
var catalogue = map[string][]Intent{
/* ── Control Center — the platform read as a whole ─────────────────── */
"control-center": {
{
ID: "platform-health", Text: "How healthy is the platform right now?",
Subject: "platform health", Shapes: []string{"stats", "card", "insight"},
Terms: []string{"health", "healthy", "platform", "integrity", "data quality",
"status", "wrong", "broken", "degraded"},
Reads: []Need{postings, applications, interviews, profiles},
},
{
ID: "workforce-summary", Text: "Summarize the workforce across the platform",
Subject: "the workforce", Shapes: []string{"stats", "table", "progress", "card"},
Terms: []string{"workforce", "scale", "how big", "composition", "headcount",
"people", "how many"},
Reads: []Need{postings, profiles, staff},
},
{
ID: "hiring-operations", Text: "How is hiring operating overall?",
Subject: "hiring activity", Shapes: []string{"flow", "stats", "timeline", "table", "card"},
Terms: []string{"hiring", "operations", "activity", "velocity", "speed",
"throughput", "how fast", "time to hire"},
Reads: []Need{applications, postings},
},
{
ID: "pipeline-health", Text: "Where is the hiring pipeline getting stuck?",
Subject: "the hiring pipeline", Shapes: []string{"flow", "stats", "progress", "table", "card"},
Terms: []string{"pipeline", "funnel", "bottleneck", "stuck", "blocked",
"conversion", "stage", "stages", "drop off", "dropoff"},
Reads: []Need{applications},
},
{
ID: "attention-required", Text: "What needs attention right now?",
Subject: "what needs attention", Shapes: []string{"list", "table", "insight"},
Terms: []string{"attention", "urgent", "priority", "action", "unusual",
"anomaly", "anomalous", "risk", "risks", "problem", "problems", "issue"},
Reads: []Need{applications, postings},
},
{
ID: "recommendations", Text: "What should I do next?",
Subject: "the recommendations", Shapes: []string{"list", "insight"},
Terms: []string{"recommend", "recommendation", "recommendations", "suggest",
"should", "advice", "next step", "next"},
Reads: []Need{applications, postings},
},
},
/* ── Positions — the roles being filled ────────────────────────────── */
"positions": {
{
ID: "position-drafts", Text: "Which positions are still unfinished drafts?",
Subject: "the unfinished drafts", Shapes: []string{"list", "table"},
Terms: []string{"draft", "drafts", "unfinished", "incomplete", "unpublished",
"not posted", "half", "position", "positions", "role", "roles"},
Reads: []Need{postings},
},
{
ID: "position-strength", Text: "Which position has the strongest pipeline?",
Subject: "pipeline strength by position", Shapes: []string{"table", "stats", "list", "card"},
Terms: []string{"pipeline", "strength", "strongest", "healthiest", "best",
"conversion", "which position", "compare", "position", "positions",
"role", "roles"},
Reads: []Need{postings, applications},
},
{
ID: "positions-attention", Text: "Which positions need attention?",
Subject: "positions needing attention", Shapes: []string{"list", "table", "insight"},
Terms: []string{"attention", "risk", "risks", "at risk", "stalled", "stale",
"ageing", "aging", "neglected", "urgent", "slipping", "behind",
"position", "positions", "role", "roles"},
Reads: []Need{postings, applications},
},
{
ID: "hiring-priority", Text: "Which position should I fill first?",
Subject: "what to fill first", Shapes: []string{"list", "table"},
Terms: []string{"priority", "prioritise", "prioritize", "fill", "fill first",
"first", "most important", "which position", "vacancy", "vacancies",
"position", "positions", "role", "roles"},
Reads: []Need{postings, applications},
},
{
ID: "pipeline-health", Text: "Where are the hiring bottlenecks?",
Subject: "the hiring bottlenecks", Shapes: []string{"flow", "stats", "progress", "table"},
Terms: []string{"bottleneck", "bottlenecks", "funnel", "stuck", "blocked",
"stage", "stages", "waiting", "backlog", "pipeline"},
Reads: []Need{applications},
},
{
ID: "hiring-operations", Text: "Summarize hiring activity across all positions",
Subject: "hiring activity", Shapes: []string{"flow", "stats", "timeline", "table", "card"},
Terms: []string{"hiring", "activity", "operations", "throughput", "velocity",
"how many", "posting", "postings", "role", "roles", "position", "positions"},
Reads: []Need{applications, postings},
},
{
/* Deliberately NOT carrying "pipeline". This is the Positions page's
reading of who is waiting on a decision, and it belongs here — the
frontend manifest declares it on this page. But "pipeline" on
Positions is a question about how the ROLES are converting, and
answering it with a list of people is the page context being
right and the ranking being wrong. Its own words are what it
answers to. */
ID: "candidates-waiting", Text: "Which candidates are waiting on a decision?",
Subject: "the candidates waiting", Shapes: []string{"list", "table", "stats"},
Terms: []string{"waiting", "candidate", "candidates", "applicant", "applicants",
"review", "screen", "screening", "decision", "queue"},
Reads: []Need{applications},
},
},
/* ── Candidates — the people in the funnel, as a triage queue ──────── */
"candidates": {
{
ID: "candidates-attention", Text: "Which candidates need attention?",
Subject: "the candidates needing attention", Shapes: []string{"list", "table", "insight"},
Terms: []string{"attention", "waiting", "stalled", "overdue", "action",
"decision", "decide", "urgent", "candidate", "candidates", "applicant",
"applicants"},
Reads: []Need{applications},
},
{
ID: "top-candidates", Text: "Who are the strongest candidates?",
Subject: "the strongest candidates", Shapes: []string{"list", "table", "stats", "card"},
Terms: []string{"strongest", "top", "best", "compare", "shortlist", "rank",
"ranking", "highest", "score", "scores", "scored", "scoring", "who",
"candidate", "candidates", "applicant", "applicants"},
Reads: []Need{applications},
},
{
ID: "interview-ready", Text: "Who is ready to interview?",
Subject: "the interview-ready candidates", Shapes: []string{"list", "table", "stats"},
Terms: []string{"interview", "interviews", "interviewed", "ready", "schedule",
"next round", "shortlist", "candidate", "candidates", "applicant",
"applicants"},
Reads: []Need{applications, interviews},
},
{
ID: "screening-gaps", Text: "Which candidates have not been scored yet?",
Subject: "the screening gaps", Shapes: []string{"list", "table", "stats", "progress"},
Terms: []string{"unscored", "score", "scores", "scoring", "screening", "screen",
"gap", "gaps", "missing", "incomplete", "coverage", "candidate",
"candidates", "applicant", "applicants"},
Reads: []Need{applications},
},
{
ID: "pipeline-summary", Text: "Summarize the candidate pipeline",
Subject: "the candidate pipeline", Shapes: []string{"flow", "stats", "progress", "table", "card"},
Terms: []string{"pipeline", "funnel", "stage", "stages", "breakdown",
"how many", "where are", "conversion"},
Reads: []Need{applications},
},
{
ID: "candidate-risk", Text: "Which candidates carry risk flags?",
Subject: "the candidate risks", Shapes: []string{"list", "table", "insight"},
Terms: []string{"risk", "risks", "risky", "flag", "flags", "flagged", "concern",
"concerns", "integrity", "doubt", "decision", "candidate", "candidates",
"applicant", "applicants"},
Reads: []Need{applications, interviews},
},
},
/* ── Candidates Analysis — the same records read as a pool ─────────── */
"candidates-analysis": {
{
ID: "candidate-risk", Text: "Where is candidate risk concentrated?",
Subject: "candidate risk", Shapes: []string{"table", "stats", "insight"},
Terms: []string{"risk", "risks", "flag", "flags", "flagged", "concern",
"integrity", "concentrated"},
Reads: []Need{applications, interviews},
},
{
ID: "recruitment-insights", Text: "What do the recruitment numbers show?",
Subject: "the recruitment insights", Shapes: []string{"stats", "table", "insight", "card"},
Terms: []string{"insight", "insights", "recruitment", "quality", "pool",
"stands out", "numbers", "trend", "trends"},
Reads: []Need{applications, profiles},
},
{
ID: "screening-gaps", Text: "Where are the screening gaps?",
Subject: "the screening gaps", Shapes: []string{"table", "stats", "progress"},
Terms: []string{"screening", "screen", "gap", "gaps", "unscored", "coverage",
"missing", "incomplete", "score", "scores"},
Reads: []Need{applications},
},
{
ID: "hiring-recommendations", Text: "Who should we hire?",
Subject: "the hiring recommendations", Shapes: []string{"list", "table", "insight"},
Terms: []string{"recommend", "recommendation", "recommendations", "hire",
"hiring", "should", "advice", "decision", "who"},
Reads: []Need{applications, profiles},
},
{
ID: "top-candidates", Text: "Compare the top candidates",
Subject: "the top candidates", Shapes: []string{"table", "list", "stats"},
Terms: []string{"compare", "comparison", "top", "best", "strongest",
"shortlist", "rank", "ranking", "side by side"},
Reads: []Need{applications},
},
},
/* ── Analytics — performance over time ─────────────────────────────── */
"analytics": {
{
ID: "hiring-trend", Text: "How has hiring trended over time?",
Subject: "the hiring trend", Shapes: []string{"timeline", "flow", "stats", "table"},
Terms: []string{"trend", "trends", "trending", "over time", "month", "monthly",
"week", "weekly", "history", "growth", "change"},
Reads: []Need{applications, postings},
},
{
ID: "department-performance", Text: "How is each department performing?",
Subject: "department performance", Shapes: []string{"table", "stats", "progress"},
Terms: []string{"department", "departments", "team", "teams", "category",
"categories", "performance", "performing", "breakdown", "compare"},
Reads: []Need{applications, postings},
},
{
ID: "pipeline-health", Text: "Where are the hiring bottlenecks?",
Subject: "the hiring bottlenecks", Shapes: []string{"flow", "stats", "progress", "table"},
Terms: []string{"bottleneck", "bottlenecks", "funnel", "pipeline", "conversion",
"stuck", "stage", "stages"},
Reads: []Need{applications},
},
{
ID: "position-conversion", Text: "Which positions convert best?",
Subject: "position conversion", Shapes: []string{"table", "stats", "list"},
Terms: []string{"conversion", "convert", "converts", "position", "positions",
"role", "roles", "rate", "rates", "ratio", "yield"},
Reads: []Need{postings, applications},
},
{
ID: "hiring-operations", Text: "How is hiring performing overall?",
Subject: "hiring performance", Shapes: []string{"stats", "flow", "timeline", "card"},
Terms: []string{"hiring", "performance", "operations", "velocity", "speed",
"average", "averages", "time to hire", "throughput"},
Reads: []Need{applications, postings},
},
{
ID: "attention-required", Text: "What needs attention in the numbers?",
Subject: "what needs attention", Shapes: []string{"list", "insight", "table"},
Terms: []string{"attention", "outlier", "outliers", "anomaly", "unusual",
"risk", "risks", "worst", "falling"},
Reads: []Need{applications, postings},
},
},
/* ── Activity — the audit log ──────────────────────────────────────── */
"activity": {
{
ID: "audit-summary", Text: "Summarize the audit log",
Subject: "the audit log", Shapes: []string{"stats", "table", "timeline", "card"},
Terms: []string{"audit", "log", "logs", "event", "events", "activity",
"trail", "record", "records", "how many"},
Reads: []Need{orgActivity},
},
{
ID: "user-activity", Text: "Who has been most active?",
Subject: "activity by user", Shapes: []string{"table", "list", "stats"},
Terms: []string{"user", "users", "who", "account", "accounts", "busiest",
"most active", "behaviour", "behavior", "person"},
Reads: []Need{orgActivity},
},
{
ID: "unusual-activity", Text: "Has anything unusual happened?",
Subject: "the unusual activity", Shapes: []string{"list", "timeline", "insight"},
Terms: []string{"unusual", "anomaly", "anomalies", "anomalous", "suspicious",
"spike", "spikes", "burst", "odd", "strange", "out of hours"},
Reads: []Need{orgActivity},
},
{
ID: "security-insights", Text: "Are there any security concerns?",
Subject: "the security findings", Shapes: []string{"list", "insight", "table"},
Terms: []string{"security", "secure", "breach", "compliance", "compliant",
"integrity", "trace", "traceable", "attributable", "risk", "risks"},
Reads: []Need{orgActivity},
},
},
/* ── Talent Pool — supply, before anyone applies ───────────────────── */
"talent-pool": {
{
ID: "talent-priorities", Text: "Who should I prioritize in the talent pool?",
Subject: "the talent priorities", Shapes: []string{"list", "table", "stats"},
Terms: []string{"prioritise", "prioritize", "priority", "priorities", "who",
"best", "top", "strongest", "elite", "star", "shortlist", "score", "scores"},
Reads: []Need{profiles},
},
{
ID: "talent-summary", Text: "Summarize the talent pool",
Subject: "the talent pool", Shapes: []string{"stats", "table", "progress", "card"},
Terms: []string{"pool", "talent", "worker", "workers", "profile", "profiles",
"segment", "segments", "composition", "supply", "how many"},
Reads: []Need{profiles},
},
{
ID: "talent-verification", Text: "Which profiles are missing verification?",
Subject: "the verification gaps", Shapes: []string{"list", "table", "progress", "stats"},
Terms: []string{"verification", "verify", "verified", "unverified", "gap",
"gaps", "missing", "credential", "credentials", "proof", "evidence"},
Reads: []Need{profiles, evidence},
},
{
ID: "talent-availability", Text: "Who is available to start?",
Subject: "availability", Shapes: []string{"list", "table", "stats"},
Terms: []string{"available", "availability", "unavailable", "free", "start",
"capacity", "when", "now", "notice"},
Reads: []Need{profiles},
},
},
/* ── Hired History — what happened after the hire ───────────────────── */
"hired-history": {
{
ID: "hiring-outcomes", Text: "How have our hires worked out?",
Subject: "the hiring outcomes", Shapes: []string{"stats", "table", "progress", "card"},
Terms: []string{"outcome", "outcomes", "result", "results", "quality",
"retention", "worked out", "performance", "department", "departments"},
Reads: []Need{staff, applications},
},
{
ID: "hiring-strongest", Text: "Who are our strongest hires?",
Subject: "the strongest hires", Shapes: []string{"list", "table", "stats"},
Terms: []string{"strongest", "best", "top", "star", "highest", "score",
"scores", "standout"},
Reads: []Need{staff, applications},
},
{
ID: "hiring-patterns", Text: "What stands out about who we hire?",
Subject: "the hiring patterns", Shapes: []string{"table", "stats", "insight"},
Terms: []string{"pattern", "patterns", "stands out", "trend", "trends",
"common", "typical", "breakdown", "profile"},
Reads: []Need{staff, applications},
},
{
ID: "hiring-recent", Text: "Who did we hire recently?",
Subject: "the recent hires", Shapes: []string{"list", "timeline", "table"},
Terms: []string{"recent", "recently", "latest", "last", "new hire", "new hires",
"who did we hire", "this month", "hired"},
Reads: []Need{staff},
},
},
/* ── KROW Forge — the skill library and the proof behind it ────────── */
"krow-forge": {
{
ID: "forge-library", Text: "What is in the skill library?",
Subject: "the skill library", Shapes: []string{"stats", "table", "list", "card"},
Terms: []string{"library", "skill", "skills", "catalogue", "catalog", "course",
"courses", "challenge", "challenges", "what do we have", "how many"},
Reads: []Need{courses},
},
{
ID: "forge-published", Text: "Which skills are published?",
Subject: "the published skills", Shapes: []string{"list", "table", "stats"},
Terms: []string{"published", "publish", "live", "draft", "drafts", "archived",
"archive", "status", "in service"},
Reads: []Need{courses},
},
{
ID: "forge-evaluation", Text: "How is submitted proof evaluated?",
Subject: "the evaluation criteria", Shapes: []string{"list", "table", "insight"},
Terms: []string{"evaluate", "evaluated", "evaluation", "rubric", "rubrics",
"criteria", "criterion", "grading", "graded", "proof", "verify",
"verification", "assess"},
Reads: []Need{courses, evidence},
},
{
ID: "forge-workforce", Text: "How is the workforce using the Forge?",
Subject: "workforce usage", Shapes: []string{"stats", "progress", "table"},
Terms: []string{"workforce", "usage", "using", "uptake", "adoption", "progress",
"completion", "completed", "training", "learning"},
Reads: []Need{courses, evidence, profiles},
},
{
ID: "forge-gaps", Text: "Which skill gaps are still open?",
Subject: "the skill gaps", Shapes: []string{"list", "table", "progress"},
Terms: []string{"gap", "gaps", "missing", "uncovered", "close", "coverage",
"needed", "shortfall", "certification", "certifications"},
Reads: []Need{courses, certs, profiles},
},
},
/* ── Create Position — the authoring form ──────────────────────────── */
"create-position": {
{
ID: "vetting-weights", Text: "What do the vetting weights score?",
Subject: "the vetting weights", Shapes: []string{"table", "insight"},
Terms: []string{"weight", "weights", "weighting", "weightings", "vetting",
"criteria", "criterion", "scoring", "score", "balance", "importance"},
Reads: []Need{postings},
},
{
ID: "position-benchmarks", Text: "How does this compare with similar roles?",
Subject: "the benchmarks", Shapes: []string{"table", "stats", "insight"},
Terms: []string{"compare", "comparison", "benchmark", "benchmarks", "similar",
"typical", "average", "pay", "rate", "rates", "salary", "experience",
"market"},
Reads: []Need{postings},
},
{
ID: "position-requirements", Text: "Which credentials are already in use?",
Subject: "the credentials in use", Shapes: []string{"list", "table", "stats"},
Terms: []string{"credential", "credentials", "certification", "certifications",
"requirement", "requirements", "qualification", "qualifications",
"licence", "license", "skill", "skills"},
Reads: []Need{postings, certs},
},
{
ID: "position-spec-steps", Text: "How does this form work?",
Terms: []string{"form", "field", "fields", "step", "steps", "section",
"sections", "how do i", "what do i", "explain", "fill in", "required"},
},
},
/* ── Profile — the account, not the workforce ──────────────────────── */
"profile": {
{
ID: "profile-permissions", Text: "What am I permitted to do?",
Terms: []string{"permission", "permissions", "permitted", "allowed", "can i",
"scope", "access", "role", "rights", "privilege", "privileges"},
},
{
ID: "profile-identity", Text: "What are my account details?",
Terms: []string{"account", "details", "name", "email", "identity", "profile",
"who am i", "my details"},
},
{
ID: "profile-security", Text: "How is my account secured?",
Terms: []string{"security", "secure", "secured", "password", "two-factor",
"two factor", "2fa", "session", "sessions", "sign out", "log out",
"protect", "protected"},
},
{
ID: "profile-preferences", Text: "Which preferences are set?",
Terms: []string{"preference", "preferences", "setting", "settings", "digest",
"density", "owliver", "default", "defaults", "toggle"},
},
{
ID: "profile-activity", Text: "What have I done recently?",
Subject: "my recent activity", Shapes: []string{"timeline", "list", "table"},
Terms: []string{"activity", "recent", "recently", "history", "what have i",
"my actions", "audit", "did i"},
Reads: []Need{ownActivity},
},
{
ID: "profile-actions", Text: "What can I do on this page?",
Terms: []string{"do here", "what can i", "action", "actions", "edit", "change",
"change my", "update", "manage"},
},
},
}
// Pages is every page the catalogue holds intents for, for tests and
// diagnostics. It is not the set of valid pages — that is
// definition.SupportedPages, and a valid page absent from here answers with an
// empty list rather than a validation error.
func Pages() []string {
out := make([]string, 0, len(catalogue))
for page := range catalogue {
out = append(out, page)
}
return out
}

View File

@@ -0,0 +1,359 @@
package owliver
import (
"sort"
"strings"
"unicode"
"github.com/krow/krow-backend/go-api/internal/domain"
)
// MaxSuggestions is the most a response may carry.
//
// Three, because the panel shows them under a composer the user is still typing
// into. A fourth line pushes the input off a phone screen, and a ranked list
// nobody reads to the bottom is a longer list, not a better one.
const MaxSuggestions = 3
// MinQueryChars is the shortest query that is worth ranking.
//
// Counted in letters and digits after normalization, so " a " and "?!" are
// both too short. One character matches a prefix of almost every term in the
// catalogue, which would make the first keystroke return three arbitrary
// readings and the second replace all three — noise that reads as a bug.
const MinQueryChars = 2
// MaxQueryChars bounds the work one request can ask for. The panel sends what
// is in the composer, and nothing about a suggestion improves past a couple of
// sentences. Beyond this the query is truncated, never rejected: a long paste
// should rank on its opening words, not fail.
const MaxQueryChars = 200
// Suggestion is one offered question.
//
// Text is what the user reads. Intent is the frontend capability id the panel
// dispatches on. Capability is the section type the answer should be drawn as,
// present only when the query asked for one — nothing internal is exposed here:
// no terms, no resource names, no policy detail, no scores.
type Suggestion struct {
Text string `json:"text"`
Intent string `json:"intent"`
Capability string `json:"capability,omitempty"`
}
/* ── Shapes ─────────────────────────────────────────────────────────────── */
// shape is one section type an answer can be drawn as.
//
// Transcribed from OWLIVER_CAPABILITIES in src/lib/skills/surfaces.js: the ids
// and the terms are that table's, and `phrase` is how the id reads inside a
// sentence. `summary` is first and has no component — it is prose, so it
// applies to any intent with a subject.
//
// Terms here describe the SHAPE and never a subject, which is what keeps a
// shape from dragging in another page's readings: "as a flow" belongs here,
// "hiring activity" belongs to an intent.
type shape struct {
id string
phrase string
terms []string
}
var shapes = []shape{
{id: "summary", phrase: "", terms: []string{
"summary", "summarise", "summarize", "summarised", "summarized",
"summarising", "summarizing", "sum up", "recap", "overview", "brief me",
"in short", "tell me about"}},
{id: "flow", phrase: "as a flow", terms: []string{
"flow", "as a flow", "chart", "graph", "diagram", "funnel", "visual",
"visualise", "visualize", "step by step"}},
{id: "stats", phrase: "as stats", terms: []string{
"stats", "statistics", "figures", "numbers", "counts"}},
{id: "list", phrase: "as a list", terms: []string{
"list", "which ones", "show me the records"}},
{id: "table", phrase: "as a table", terms: []string{
"table", "as a table", "rows", "grid", "spreadsheet"}},
{id: "timeline", phrase: "as a timeline", terms: []string{
"timeline", "chronology", "over time", "what happened"}},
{id: "progress", phrase: "as progress bars", terms: []string{
"progress", "bars", "completion", "how far"}},
{id: "weights", phrase: "as weights", terms: []string{
"weighting", "weightings", "set the weights", "adjust the weights",
"screening weight", "vetting weight"}},
{id: "insight", phrase: "as an insight", terms: []string{
"insight", "finding", "takeaway", "headline"}},
{id: "card", phrase: "as a card", terms: []string{
"card", "panel", "at a glance"}},
}
/* ── Normalization ──────────────────────────────────────────────────────── */
// normalize reduces a raw query to the one form everything downstream matches
// against: lower case, letters and digits only, single-spaced.
//
// Every other character — punctuation, quotes, brackets, control characters,
// emoji, an SQL fragment, a script tag — becomes a space rather than being
// stripped, so nothing can be glued into a token that was not typed as one.
// The result is compared against a fixed table of literals and never reaches a
// query, a template or a log message, so there is no construction to inject
// into; this is about matching sanely, not about escaping.
func normalize(raw string) (phrase string, tokens []string, meaningful int) {
runes := []rune(raw)
if len(runes) > MaxQueryChars {
runes = runes[:MaxQueryChars]
}
var b strings.Builder
b.Grow(len(runes))
for _, r := range runes {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
meaningful++
default:
b.WriteByte(' ')
}
}
tokens = strings.Fields(b.String())
return strings.Join(tokens, " "), tokens, meaningful
}
/* ── Scoring ────────────────────────────────────────────────────────────── */
// Scores are small integers with a deliberate order:
//
// exact a token is the term — the user typed it
// phrase a multi-word term appears in the query — the most specific hit
// prefix the term begins with a token — mid-typing: "pipel"
// extension a token begins with the term — "pipelines"
// shaped the query named a section type — weakest on its own
//
// A shape hit is worth less than any subject hit, so typing "overview" can
// surface a page's readings but can never outrank a reading the user named.
const (
scoreExact = 10
scorePhrase = 12
scorePrefix = 6
scoreExtension = 5
scoreShaped = 4
// minPrefixToken keeps one- and two-letter tokens from matching a term by
// prefix. "a" begins nothing usefully; "at" would match "attention",
// "audit" and "activity" at once.
minPrefixToken = 3
// minExtensionTerm keeps a short term from being found inside a longer
// word: without it "list" matches "listen" and "score" matches "scoreboard".
minExtensionTerm = 4
)
// tokenScore is how well one typed token matches one single-word term.
func tokenScore(token, term string) int {
switch {
case token == term:
return scoreExact
case len(token) >= minPrefixToken && strings.HasPrefix(term, token):
return scorePrefix
case len(term) >= minExtensionTerm && strings.HasPrefix(token, term):
return scoreExtension
default:
return 0
}
}
// termsScore ranks a whole term list against the query.
//
// Multi-word terms are matched against the phrase, because "how many" means
// something its two words do not. Single-word terms are scored per TYPED TOKEN,
// taking that token's best term — so a query is rewarded for how much of what
// the user typed the intent accounts for, and an intent cannot climb the
// ranking by listing eight synonyms of one word.
func termsScore(terms []string, tokens []string, phrase string) int {
total := 0
for _, term := range terms {
if !strings.Contains(term, " ") {
continue
}
if strings.Contains(phrase, term) {
total += scorePhrase + 2*strings.Count(term, " ")
}
}
for _, token := range tokens {
best := 0
for _, term := range terms {
if strings.Contains(term, " ") {
continue
}
if s := tokenScore(token, term); s > best {
best = s
}
}
total += best
}
return total
}
// matchShape is the section type the query asked for, if it asked for one.
// Highest scoring wins; ties go to declaration order, which puts `summary`
// first.
func matchShape(tokens []string, phrase string) (shape, bool) {
best, bestScore := shape{}, 0
for _, s := range shapes {
if score := termsScore(s.terms, tokens, phrase); score > bestScore {
best, bestScore = s, score
}
}
return best, bestScore > 0
}
// supportsShape reports whether an intent can be drawn as a section type.
// `summary` needs only a subject, having no component of its own; every other
// shape must be one the intent declares.
func (i Intent) supportsShape(id string) bool {
if i.Subject == "" {
return false
}
if id == "summary" {
return true
}
for _, s := range i.Shapes {
if s == id {
return true
}
}
return false
}
// shaped is the suggestion text for an intent asked for in a given shape.
func (i Intent) shaped(s shape) string {
if s.id == "summary" {
return "Summarize " + i.Subject
}
return "Show " + i.Subject + " " + s.phrase
}
/* ── The pipeline ───────────────────────────────────────────────────────── */
// filterOnTopic keeps the candidates the query actually named, or nothing if
// it named none.
func filterOnTopic(candidates []scored) []scored {
out := make([]scored, 0, len(candidates))
for _, c := range candidates {
if c.onTopic {
out = append(out, c)
}
}
return out
}
// scored is one candidate on its way through ranking.
type scored struct {
suggestion Suggestion
score int
order int // declaration index, the tie-break
// onTopic records that the query matched this reading's own terms, rather
// than only naming a section type it happens to support. See Suggest.
onTopic bool
}
// Suggest ranks the page's catalogue against what the user has typed.
//
// The order is fixed and each stage only ever removes: page context, then
// permission, then relevance, then duplicates, then the cap. Permission comes
// before relevance so a reading the caller cannot perform is never scored, and
// therefore cannot be leaked by an ordering bug later.
//
// It returns an empty slice, never nil and never a filler suggestion: a query
// that matches nothing on this page has no answer here, and saying so is more
// useful than three questions the user did not ask.
func Suggest(page, query string, role domain.Role) []Suggestion {
out := []Suggestion{}
// Deny by default, as policy.go does. The service resolves the role before
// calling, so an unrecognised one should be unreachable — but an intent
// that reads nothing is permitted by every role it is asked about, so
// without this line a caller whose role failed to parse would be offered
// the account readings. The check belongs where the answer is decided.
if _, known := domain.ParseRole(string(role)); !known {
return out
}
intents, ok := catalogue[page]
if !ok {
return out
}
phrase, tokens, meaningful := normalize(query)
if meaningful < MinQueryChars {
return out
}
requested, wantsShape := matchShape(tokens, phrase)
candidates := make([]scored, 0, len(intents))
for order, intent := range intents {
if !intent.permitted(role) {
continue
}
score := termsScore(intent.Terms, tokens, phrase)
onTopic := score > 0
suggestion := Suggestion{Text: intent.Text, Intent: intent.ID}
if wantsShape && intent.supportsShape(requested.id) {
score += scoreShaped
suggestion.Text = intent.shaped(requested)
suggestion.Capability = requested.id
}
if score == 0 {
continue
}
candidates = append(candidates, scored{
suggestion: suggestion, score: score, order: order, onTopic: onTopic,
})
}
// A shape on its own is a weak signal, and what it means depends on what
// else matched. "as a table" typed alone is a real request — draw this
// page's readings that way — but the same words after "hiring activity" are
// how the user asked for ONE reading, and offering two more that merely
// support tables is the padding this endpoint is supposed to refuse.
//
// So the two are kept in separate tiers: if anything matched the query's
// subject, only those compete. Shape-only matches answer for the whole page
// or not at all.
if onTopic := filterOnTopic(candidates); len(onTopic) > 0 {
candidates = onTopic
}
// Highest score first; declaration order breaks every tie, so the same
// request always produces the same three in the same sequence.
sort.SliceStable(candidates, func(a, b int) bool {
if candidates[a].score != candidates[b].score {
return candidates[a].score > candidates[b].score
}
return candidates[a].order < candidates[b].order
})
// One suggestion per intent, and no two reading the same. The catalogue is
// unique per page by construction — TestCatalogueIsWellFormed holds it that
// way — so this guards the shaped rewrite, which can phrase two intents
// identically only if two subjects ever collide.
seenIntent := make(map[string]bool, MaxSuggestions)
seenText := make(map[string]bool, MaxSuggestions)
for _, c := range candidates {
if len(out) == MaxSuggestions {
break
}
key := strings.ToLower(c.suggestion.Text)
if seenIntent[c.suggestion.Intent] || seenText[key] {
continue
}
seenIntent[c.suggestion.Intent] = true
seenText[key] = true
out = append(out, c.suggestion)
}
return out
}

View File

@@ -0,0 +1,695 @@
package owliver
import (
"fmt"
"reflect"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/definition"
"github.com/krow/krow-backend/go-api/internal/domain"
)
// These tests need no database and no server: the catalogue is static and the
// ranking is a pure function of (page, query, role). That is the property worth
// protecting — an endpoint the panel calls on every keystroke should be
// testable at the speed of a string comparison.
// intents is the ids Suggest returned, in order.
func intents(got []Suggestion) []string {
out := make([]string, len(got))
for i, s := range got {
out[i] = s.Intent
}
return out
}
// ask is Suggest for an admin, the role the Owliver panel is placed for.
func ask(page, query string) []Suggestion {
return Suggest(page, query, domain.RoleAdmin)
}
/* ── Control Center ─────────────────────────────────────────────────────── */
func TestControlCenterSuggestions(t *testing.T) {
cases := []struct {
name string
query string
want []string
}{
{"attention", "attention", []string{"attention-required"}},
{"pipeline", "pipeline", []string{"pipeline-health"}},
{"health", "health", []string{"platform-health"}},
{"recommendation", "what should i do", []string{"recommendations"}},
// A question about a subject this page does not hold. The Control
// Center reads the platform, not the training library.
{"irrelevant", "forklift certification renewal", nil},
{"empty", "", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := intents(ask("control-center", c.query))
if len(c.want) == 0 {
if len(got) != 0 {
t.Fatalf("query %q: want no suggestions, got %v", c.query, got)
}
return
}
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("query %q: got %v, want %v", c.query, got, c.want)
}
})
}
}
/* ── Positions ──────────────────────────────────────────────────────────── */
func TestPositionsSuggestions(t *testing.T) {
cases := []struct {
name string
query string
want []string
}{
// The page's own noun offers the page's readings, in declaration order.
{"position", "position", []string{"position-drafts", "position-strength", "positions-attention"}},
// Pipeline on Positions is a question about the ROLES: which one is
// converting, and where it is stuck. Two answers, not three — the third
// slot is left empty rather than filled with the page's list of people
// waiting, which is a different question wearing a nearby word.
{"pipeline", "pipeline", []string{"position-strength", "pipeline-health"}},
{"attention", "attention", []string{"positions-attention"}},
{"risk", "risk", []string{"positions-attention"}},
{"drafts", "draft", []string{"position-drafts"}},
{"fill first", "what should i fill first", []string{"hiring-priority"}},
// Attendance is a workforce reading and belongs to another surface. It
// must not fall through to this page's default report.
{"irrelevant", "attendance last week", nil},
{"empty", "", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := intents(ask("positions", c.query))
if len(c.want) == 0 {
if len(got) != 0 {
t.Fatalf("query %q: want no suggestions, got %v", c.query, got)
}
return
}
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("query %q: got %v, want %v", c.query, got, c.want)
}
})
}
}
/* ── Candidates ─────────────────────────────────────────────────────────── */
func TestCandidatesSuggestions(t *testing.T) {
cases := []struct {
name string
query string
want []string
}{
{"candidate", "candidate", []string{"candidates-attention", "top-candidates", "interview-ready"}},
{"score", "score", []string{"top-candidates", "screening-gaps"}},
{"interview", "interview", []string{"interview-ready"}},
{"decision", "decision", []string{"candidates-attention", "candidate-risk"}},
{"pipeline", "pipeline", []string{"pipeline-summary"}},
{"risk", "risk", []string{"candidate-risk"}},
{"irrelevant", "payroll export", nil},
{"empty", "", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := intents(ask("candidates", c.query))
if len(c.want) == 0 {
if len(got) != 0 {
t.Fatalf("query %q: want no suggestions, got %v", c.query, got)
}
return
}
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("query %q: got %v, want %v", c.query, got, c.want)
}
})
}
}
/* ── Page context is the first filter ───────────────────────────────────── */
// One keyword, every page: the answers must differ, and none may name a
// reading belonging to another surface.
func TestSameKeywordDiffersByPage(t *testing.T) {
const query = "pipeline"
seen := map[string][]string{}
for _, page := range Pages() {
got := intents(ask(page, query))
if len(got) == 0 {
continue
}
seen[page] = got
for _, id := range got {
if !declaredOn(page, id) {
t.Fatalf("page %q returned %q, which it does not declare", page, id)
}
}
}
if len(seen) < 2 {
t.Fatalf("%q matched on %d pages; the comparison needs at least two", query, len(seen))
}
if reflect.DeepEqual(seen["positions"], seen["candidates"]) {
t.Fatalf("positions and candidates both answered %q with %v", query, seen["positions"])
}
// The pipeline reading on Candidates is the candidates' own.
if !reflect.DeepEqual(seen["candidates"], []string{"pipeline-summary"}) {
t.Fatalf("candidates answered %q with %v", query, seen["candidates"])
}
}
// An unknown page is not this package's error to raise — the service refuses it
// during parsing. Reached directly it answers empty rather than borrowing
// another page's readings.
func TestUnknownPageIsEmpty(t *testing.T) {
for _, page := range []string{"", "nowhere", "POSITIONS", "settings"} {
if got := ask(page, "pipeline"); len(got) != 0 {
t.Fatalf("page %q: got %v, want none", page, got)
}
}
}
func declaredOn(page, id string) bool {
for _, i := range catalogue[page] {
if i.ID == id {
return true
}
}
return false
}
/* ── Query handling ─────────────────────────────────────────────────────── */
func TestQueryNormalization(t *testing.T) {
want := intents(ask("positions", "pipeline"))
if len(want) == 0 {
t.Fatal("the baseline query matched nothing")
}
// Every one of these is the same question typed differently: case,
// surrounding whitespace, punctuation and control characters carry no
// meaning, so all of them must rank identically.
for _, query := range []string{
" pipeline ", "PIPELINE", "PiPeLiNe", "\tpipeline\n",
"pipeline?", "\"pipeline\"", "pipeline!!!", "…pipeline…",
"(pipeline)", "**pipeline**", "pipeline\x00\x01", "\u200bpipeline",
} {
if got := intents(ask("positions", query)); !reflect.DeepEqual(got, want) {
t.Errorf("query %q: got %v, want %v", query, got, want)
}
}
}
// Hostile input is data like any other. There is no query to inject into — the
// normalized text is compared against a fixed table of literals and never
// reaches SQL, a template or a shell — so the property under test is that such
// a query is ranked rather than refused, and that it can only ever produce
// entries this page declares.
func TestHostileInputIsJustText(t *testing.T) {
for _, query := range []string{
"pipeline'; DROP TABLE job_postings; --",
"pipeline\" OR 1=1 --",
"<script>alert('pipeline')</script>",
"{{7*7}} pipeline ${jndi:ldap://x/y}",
"../../etc/passwd pipeline",
"pipeline%00%0d%0aSet-Cookie:+x=1",
strings.Repeat("' OR ''='", 40),
} {
for _, s := range ask("positions", query) {
if !declaredOn("positions", s.Intent) {
t.Errorf("query %q produced %q, which positions does not declare", query, s.Intent)
}
if !declaredText("positions", s) {
t.Errorf("query %q produced unrecognised text %q", query, s.Text)
}
}
}
}
// declaredText reports whether a suggestion's wording came from the catalogue —
// either an intent's own Text, or its subject phrased in a shape it declares.
// Nothing the caller typed may appear in a response.
func declaredText(page string, got Suggestion) bool {
for _, i := range catalogue[page] {
if i.ID != got.Intent {
continue
}
if got.Capability == "" {
return got.Text == i.Text
}
for _, s := range shapes {
if s.id == got.Capability {
return got.Text == i.shaped(s)
}
}
}
return false
}
// Fewer than two meaningful characters is not a question yet. Punctuation and
// whitespace are not meaningful.
func TestShortQueriesAreEmpty(t *testing.T) {
for _, query := range []string{"", " ", "\n\t ", "p", " p ", "?", "!!!", "-", "€", " , "} {
if got := ask("positions", query); len(got) != 0 {
t.Errorf("query %q: got %v, want none", query, got)
}
}
}
// The panel calls this on every keystroke, so a half-typed word has to match.
func TestPrefixMatchingWhileTyping(t *testing.T) {
full := intents(ask("positions", "pipeline"))
for _, query := range []string{"pip", "pipe", "pipel", "pipelin", "pipeline", "pipelines"} {
got := intents(ask("positions", query))
if len(got) == 0 {
t.Fatalf("query %q matched nothing; the panel would blank mid-word", query)
}
if query != "pipelines" && !reflect.DeepEqual(got, full) {
t.Errorf("query %q: got %v, want %v", query, got, full)
}
}
}
// A long paste ranks on its opening words rather than being refused.
func TestOverlongQueryIsTruncatedNotRejected(t *testing.T) {
query := "pipeline " + strings.Repeat("x", 5000)
got := intents(ask("positions", query))
if len(got) == 0 {
t.Fatal("an overlong query was refused instead of truncated")
}
if !reflect.DeepEqual(got, intents(ask("positions", "pipeline"))) {
t.Fatalf("an overlong query ranked differently: %v", got)
}
}
/* ── Shapes ─────────────────────────────────────────────────────────────── */
// Asking for a section type names it in the answer and phrases the suggestion
// in those terms — the brief's "Show hiring activity as a flow".
func TestShapedSuggestions(t *testing.T) {
got := ask("positions", "show hiring activity as a flow")
if len(got) != 1 {
t.Fatalf("got %d suggestions, want 1: %+v", len(got), got)
}
want := Suggestion{
Text: "Show hiring activity as a flow",
Intent: "hiring-operations",
Capability: "flow",
}
if got[0] != want {
t.Fatalf("got %+v, want %+v", got[0], want)
}
summarized := ask("positions", "summarize hiring activity")
if len(summarized) != 1 || summarized[0].Capability != "summary" ||
summarized[0].Text != "Summarize hiring activity" {
t.Fatalf("got %+v", summarized)
}
}
// A shape alone is a question about the page. A shape after a subject is a
// question about that subject, and the other readings that merely support the
// shape are padding — which this endpoint does not do.
func TestShapeAloneAnswersThePageButNeverPads(t *testing.T) {
alone := ask("control-center", "summarize")
if len(alone) != MaxSuggestions {
t.Fatalf("a bare shape returned %d suggestions, want %d: %+v",
len(alone), MaxSuggestions, alone)
}
for _, s := range alone {
if s.Capability != "summary" {
t.Fatalf("got capability %q, want summary: %+v", s.Capability, s)
}
}
// "attention" names one reading; nothing else may ride along on the shape.
withSubject := ask("control-center", "summarize what needs attention")
if len(withSubject) != 1 || withSubject[0].Intent != "attention-required" {
t.Fatalf("got %+v, want only attention-required", withSubject)
}
}
// A shape an intent cannot be drawn as leaves its wording alone.
func TestUnsupportedShapeIsNotClaimed(t *testing.T) {
for _, s := range ask("create-position", "adjust the weights") {
if s.Capability == "weights" {
t.Fatalf("offered a weights rendering nothing declares: %+v", s)
}
}
}
/* ── Response limits ────────────────────────────────────────────────────── */
func TestNeverMoreThanThreeAndNeverDuplicated(t *testing.T) {
// A query broad enough to match everything the page has.
queries := []string{
"position pipeline attention risk draft hiring activity waiting candidates",
"candidate score interview decision risk pipeline screening",
"summarize", "attention risk", "who what how many",
}
for _, page := range Pages() {
for _, query := range queries {
got := Suggest(page, query, domain.RoleAdmin)
if len(got) > MaxSuggestions {
t.Fatalf("page %q query %q: %d suggestions, cap is %d",
page, query, len(got), MaxSuggestions)
}
seenIntent, seenText := map[string]bool{}, map[string]bool{}
for _, s := range got {
if s.Text == "" || s.Intent == "" {
t.Fatalf("page %q query %q: incomplete suggestion %+v", page, query, s)
}
if seenIntent[s.Intent] {
t.Fatalf("page %q query %q: duplicate intent %q", page, query, s.Intent)
}
if seenText[strings.ToLower(s.Text)] {
t.Fatalf("page %q query %q: duplicate text %q", page, query, s.Text)
}
seenIntent[s.Intent], seenText[strings.ToLower(s.Text)] = true, true
}
}
}
}
// The same request must answer the same way every time — the panel re-issues it
// on every keystroke, and a list that reshuffles under the cursor is unusable.
func TestSuggestIsDeterministic(t *testing.T) {
for _, page := range Pages() {
first := Suggest(page, "attention risk pipeline summary", domain.RoleAdmin)
for i := 0; i < 20; i++ {
again := Suggest(page, "attention risk pipeline summary", domain.RoleAdmin)
if !reflect.DeepEqual(first, again) {
t.Fatalf("page %q: run %d differed\n first: %+v\n again: %+v",
page, i, first, again)
}
}
}
}
// Empty, never nil: `{"suggestions": []}` and not `{"suggestions": null}`.
func TestNoMatchIsAnEmptySliceNotNil(t *testing.T) {
for _, c := range []struct{ page, query string }{
{"positions", "sourdough"}, {"positions", ""}, {"nowhere", "pipeline"},
} {
got := ask(c.page, c.query)
if got == nil {
t.Fatalf("page %q query %q: got nil, want an empty slice", c.page, c.query)
}
if len(got) != 0 {
t.Fatalf("page %q query %q: got %v", c.page, c.query, got)
}
}
}
/* ── Authorization ──────────────────────────────────────────────────────── */
// Talent may list job applications, but only their own — so a reading across
// the organization's pipeline is not theirs to be offered, even though the
// operation itself is permitted. The same holds for postings, profiles, staff,
// evidence and the audit log.
//
// The exception is stated rather than hidden: `courses` is the one resource in
// the policy table that talent lists unscoped, because the training library is
// shared platform-wide and everybody learns from it. So the two Forge readings
// that ask only what the library holds survive, and every other Forge reading —
// evaluation, workforce usage, gaps, all of which read evidence or profiles —
// does not. If that ever widens, this test says exactly what widened.
func TestTalentIsOfferedOnlyUnscopedReadings(t *testing.T) {
pages := []string{
"control-center", "positions", "candidates", "candidates-analysis",
"analytics", "activity", "talent-pool", "hired-history", "krow-forge",
"create-position",
}
queries := []string{
"pipeline", "attention", "candidate", "position", "risk", "summarize",
"hiring", "score", "activity", "who", "how many", "library", "skill",
"published", "gaps", "evaluation", "weights", "credential",
}
allowed := map[string]bool{"krow-forge/forge-library": true, "krow-forge/forge-published": true}
for _, page := range pages {
for _, query := range queries {
for _, s := range Suggest(page, query, domain.RoleTalent) {
if !allowed[page+"/"+s.Intent] {
t.Errorf("talent was offered %q on %q for %q", s.Intent, page, query)
}
}
}
}
// And the operator's own Forge readings stay the operator's.
for _, id := range []string{"forge-evaluation", "forge-workforce", "forge-gaps"} {
for _, s := range Suggest("krow-forge", "evaluation workforce gaps", domain.RoleTalent) {
if s.Intent == id {
t.Errorf("talent was offered the operator reading %q", id)
}
}
}
if len(Suggest("krow-forge", "evaluation workforce gaps", domain.RoleAdmin)) == 0 {
t.Error("admin was offered none of them either; the query no longer matches")
}
}
// The filter is not a blanket refusal: what a talent caller may genuinely ask —
// about their own account — is still offered. Otherwise the test above would
// pass with the role check stubbed out to "deny".
func TestTalentIsStillOfferedTheirOwnReadings(t *testing.T) {
for _, query := range []string{"permission", "password", "my recent activity"} {
if got := Suggest("profile", query, domain.RoleTalent); len(got) == 0 {
t.Fatalf("talent was offered nothing on profile for %q", query)
}
}
}
// A role the API does not recognise authorizes nothing, matching policy.go.
func TestUnknownRoleIsOfferedNothing(t *testing.T) {
for _, role := range []domain.Role{"", "root", "superuser", "Admin"} {
for _, page := range Pages() {
if got := Suggest(page, "attention pipeline permission", role); len(got) != 0 {
t.Fatalf("role %q was offered %v on %q", role, intents(got), page)
}
}
}
}
// Every permission decision must come from the policy table, not from a list
// kept here. This asserts the mechanism rather than a particular outcome: an
// intent is offered exactly when policy.go allows every reading it declares.
func TestPermissionsComeFromThePolicyTable(t *testing.T) {
for _, role := range []domain.Role{domain.RoleAdmin, domain.RoleEmployer, domain.RoleTalent} {
for page, list := range catalogue {
for _, intent := range list {
want := true
for _, need := range intent.Reads {
res, ok := domain.ResourceByPath[need.Resource]
if !ok || !res.Supports(need.Op) || !res.Policy.Allows(need.Op, role) {
want = false
break
}
if need.OrgWide && res.Policy.ScopeFor(role).Kind != domain.ScopeNone {
want = false
break
}
}
if got := intent.permitted(role); got != want {
t.Errorf("%s/%s for %s: permitted=%v, policy says %v",
page, intent.ID, role, got, want)
}
}
}
}
}
/* ── The catalogue itself ───────────────────────────────────────────────── */
func TestCatalogueIsWellFormed(t *testing.T) {
for page, list := range catalogue {
if definition.CanonicalPage(page) != page {
t.Errorf("page key %q is not a canonical surface", page)
}
if len(list) == 0 {
t.Errorf("page %q has no intents; omit the key instead", page)
}
ids, texts := map[string]bool{}, map[string]bool{}
for _, intent := range list {
where := fmt.Sprintf("%s/%s", page, intent.ID)
if intent.ID == "" || intent.Text == "" {
t.Errorf("%s: an intent needs both an id and a text", where)
}
if ids[intent.ID] {
t.Errorf("%s: duplicate intent id on this page", where)
}
if texts[strings.ToLower(intent.Text)] {
t.Errorf("%s: duplicate suggestion text on this page", where)
}
ids[intent.ID], texts[strings.ToLower(intent.Text)] = true, true
if len(intent.Terms) == 0 {
t.Errorf("%s: no terms, so it can never be suggested", where)
}
for _, term := range intent.Terms {
if term != strings.ToLower(strings.TrimSpace(term)) || term == "" {
t.Errorf("%s: term %q must be lower case and trimmed", where, term)
}
if _, _, meaningful := normalize(term); meaningful < MinQueryChars {
t.Errorf("%s: term %q is shorter than the shortest query", where, term)
}
}
if len(intent.Shapes) > 0 && intent.Subject == "" {
t.Errorf("%s: declares shapes but no subject to phrase them with", where)
}
for _, id := range intent.Shapes {
if id == "summary" {
t.Errorf("%s: `summary` applies to every subject and is never declared", where)
}
if !knownShape(id) {
t.Errorf("%s: shape %q is not in the Owliver vocabulary", where, id)
}
}
for _, need := range intent.Reads {
res, ok := domain.ResourceByPath[need.Resource]
if !ok {
t.Errorf("%s: reads %q, which is not a resource", where, need.Resource)
continue
}
if !res.Supports(need.Op) {
t.Errorf("%s: reads %q with an operation it does not serve", where, need.Resource)
}
// An intent nobody can be offered is dead weight, and usually a
// typo in the resource path rather than a deliberate lockout.
if !res.Policy.Allows(need.Op, domain.RoleAdmin) {
t.Errorf("%s: reads %q, which not even admin may list", where, need.Resource)
}
}
}
}
}
// Every id in the catalogue must be a capability the frontend actually
// declares, because the id in a response is what the panel dispatches on. The
// list is the union of the manifests in
// src/components/ai-assistant/capabilities/, transcribed alongside the
// catalogue; an id here that is absent there would be a suggestion the panel
// cannot run.
func TestIntentIDsAreFrontendCapabilities(t *testing.T) {
frontend := map[string]bool{}
for _, id := range []string{
// CONTROL_CENTER_CAPABILITIES
"platform-health", "workforce-summary", "hiring-operations", "pipeline-health",
"attention-required", "recommendations",
// POSITIONS_CAPABILITIES
"position-drafts", "position-strength", "positions-attention", "hiring-priority",
"candidates-waiting",
// CANDIDATE_LIST_CAPABILITIES
"candidates-attention", "top-candidates", "interview-ready", "screening-gaps",
"pipeline-summary", "candidate-risk",
// ADMIN_CANDIDATE_CAPABILITIES
"recruitment-insights", "hiring-recommendations",
// ADMIN_ANALYTICS_CAPABILITIES
"hiring-trend", "department-performance", "position-conversion",
// ACTIVITY_CAPABILITIES
"audit-summary", "user-activity", "unusual-activity", "security-insights",
// TALENT_POOL_CAPABILITIES
"talent-priorities", "talent-summary", "talent-verification", "talent-availability",
// HIRED_HISTORY_CAPABILITIES
"hiring-outcomes", "hiring-strongest", "hiring-patterns", "hiring-recent",
// FORGE_CAPABILITIES
"forge-library", "forge-published", "forge-evaluation", "forge-workforce", "forge-gaps",
// CREATE_POSITION_CAPABILITIES
"vetting-weights", "position-benchmarks", "position-requirements", "position-spec-steps",
// PROFILE_CAPABILITIES
"profile-permissions", "profile-identity", "profile-security", "profile-preferences",
"profile-activity", "profile-actions",
} {
frontend[id] = true
}
used := map[string]bool{}
for page, list := range catalogue {
for _, intent := range list {
used[intent.ID] = true
if !frontend[intent.ID] {
t.Errorf("%s/%s names no frontend capability", page, intent.ID)
}
}
}
for id := range frontend {
if !used[id] {
t.Errorf("capability %q is declared here but suggested on no page", id)
}
}
}
func knownShape(id string) bool {
for _, s := range shapes {
if s.id == id {
return true
}
}
return false
}
// The shape vocabulary is closed and mirrors OWLIVER_CAPABILITIES.
func TestShapeVocabularyMatchesTheFrontend(t *testing.T) {
want := []string{"summary", "flow", "stats", "list", "table", "timeline",
"progress", "weights", "insight", "card"}
got := make([]string, len(shapes))
for i, s := range shapes {
got[i] = s.id
if len(s.terms) == 0 {
t.Errorf("shape %q has no terms", s.id)
}
if s.id != "summary" && s.phrase == "" {
t.Errorf("shape %q has no phrase to read inside a sentence", s.id)
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("shapes are %v, want %v", got, want)
}
}
// Nothing internal may reach a response: no terms, no resource names, no
// scores. The struct is the whole contract, so this asserts its shape.
func TestSuggestionExposesNothingInternal(t *testing.T) {
fields := reflect.VisibleFields(reflect.TypeOf(Suggestion{}))
if len(fields) != 3 {
t.Fatalf("Suggestion has %d fields; the response contract is text, intent, capability", len(fields))
}
want := map[string]string{
"Text": `json:"text"`,
"Intent": `json:"intent"`,
"Capability": `json:"capability,omitempty"`,
}
for _, f := range fields {
if string(f.Tag) != want[f.Name] {
t.Errorf("field %s has tag %q, want %q", f.Name, f.Tag, want[f.Name])
}
}
}

View File

@@ -0,0 +1,119 @@
package service
// Completing an AI interview, in one transaction.
//
// THE PROBLEM THIS SOLVES
//
// Finishing an interview is two writes: the interview record, and the
// application it was for — which has to carry the verdict forward as
// `status: interview`, `interview_id` and the score, because that is what the
// funnel and the analytics read. The frontend performed them as two independent
// requests (AIInterviewModal.finishInterview), and that had two consequences.
//
// The first is authorization. `ai-interviews:Create` is open to everyone and
// `job-applications:Update` is operators only, so a talent user sitting their
// own interview — which is the whole talent flow — got a 201 for the interview
// and a 403 for the link. The interview existed, the application still said
// `applied`, `interview_id` was never set, and every consumer that counts
// `status === 'interview' || interview_id` could not see it.
//
// The second is atomicity: even for an operator, a failure between the two left
// an interview attached to an application that did not know about it.
//
// WHY THE SERVER MAY WRITE WHAT THE CALLER MAY NOT
//
// The link is not a widening of `job-applications:Update`. A talent caller
// still cannot PATCH an application — the policy table is unchanged, and the
// role gate on that route still refuses them. What happens here is that the
// server updates the one row the interview it just wrote already names, and
// only after repo.guardInsert has proved that row belongs to the caller: a
// talent caller creating an interview for an application that is not theirs is
// answered 404 before anything is written. That guard is exactly the ownership
// proof this update needs.
//
// The alternative — adding `talent` to `job-applications:Update` with a
// per-column allowlist — was rejected in the plan for the reason the workflows
// file header gives: it would put a second authorization mechanism beside the
// per-operation one, and the two would eventually disagree.
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// InterviewsPath is the resource whose Create is routed through here.
// Exported so the HTTP layer names the same resource this file special-cases,
// rather than repeating a string literal that could drift.
const InterviewsPath = "ai-interviews"
// CreateInterview inserts an interview and links its application, atomically.
//
// The response is the interview record, unchanged: POST /api/v1/ai-interviews
// answered 201 with the created interview before this existed and answers 201
// with the created interview now. The application update is a consequence of
// the request, not a second thing in it.
func (s *WorkflowService) CreateInterview(ctx context.Context, ident authctx.Identity,
body domain.Record) (domain.Record, error) {
interviews, err := resourceByPath(InterviewsPath)
if err != nil {
return nil, err
}
apps, err := resourceByPath("job-applications")
if err != nil {
return nil, err
}
var out domain.Record
err = s.inTx(ctx, func(tx pgx.Tx) error {
// Through the resource's own service over the transaction, so the body
// is validated and the ownership guard runs exactly as they do on the
// plain create path. Nothing about the interview itself changes here.
created, err := New(interviews, tx).Create(ctx, ident, body)
if err != nil {
return err
}
out = created
applicationID, _ := created["application_id"].(string)
if applicationID == "" {
// Unreachable: application_id is NOT NULL and Required, so the
// create above would have refused. Checked rather than assumed
// because the alternative is an Update against an empty id.
return nil
}
patch := domain.Record{
"status": "interview",
"interview_id": created["id"],
}
// The score moves onto the application only when the caller said
// something about it. Copying the column unconditionally would write
// the interview's default 0 over a real screening score, which is a
// loss caused by a field the request never mentioned.
if _, said := body["overall_interview_score"]; said {
patch["ai_score"] = created["overall_interview_score"]
}
updated, err := repo.New(apps, tx).Update(ctx, ident, applicationID, patch)
if err != nil {
return err
}
if updated == nil {
// Unreachable for the same reason the guard above passed: the row
// is in this organization and, for a talent caller, theirs. Kept so
// a silent no-op cannot pass for a completed interview.
return domain.NotFound(apps.Name, applicationID)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}

View File

@@ -0,0 +1,103 @@
package service
import (
"fmt"
"net/url"
"strings"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/definition"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/owliver"
)
// allowedSuggestionParams names the accepted query parameters, on the pattern
// of allowedDefinitionFilters: anything else is a caller mistake worth saying
// out loud rather than a filter to ignore. It also keeps the endpoint from
// quietly accepting a `role`, `org` or `user` parameter should one ever be
// added by a client — who is asking is read from the session and nowhere else.
var allowedSuggestionParams = map[string]bool{
"page": true,
"query": true,
}
// maxEchoedPage bounds how much of a rejected page value is quoted back. Long
// enough to name every real surface, short enough that the error cannot be used
// to reflect a payload.
const maxEchoedPage = 64
// SuggestionQuery is a validated suggestion request.
//
// Page is canonical: aliases are resolved here so nothing downstream has to
// know that `hired` and `hired-history` are the same surface.
type SuggestionQuery struct {
Page string
Query string
}
// SuggestionsService answers "what could I usefully ask on this page?".
//
// It holds no pool, opens no transaction and reads no table. That is not an
// omission — the panel calls it while the user types, and everything it needs
// is the static catalogue in internal/owliver plus the caller's role. It is a
// service rather than a function in the handler so that validation and
// authorization sit where every other endpoint's do.
type SuggestionsService struct{}
// NewSuggestions builds the suggestion service.
func NewSuggestions() *SuggestionsService { return &SuggestionsService{} }
// ParseParams validates the query string.
//
// `page` is required and must name a real surface — the same closed vocabulary
// internal/definition validates a definition's `pages:` against, so there is
// one answer to "is that a page" in this process. `query` is optional: an
// absent or too-short one is not an error, it is a request that has nothing to
// rank yet, and Suggest answers it with an empty list.
func (s *SuggestionsService) ParseParams(q url.Values) (SuggestionQuery, error) {
var out SuggestionQuery
for name := range q {
if !allowedSuggestionParams[name] {
return out, domain.Invalid(fmt.Sprintf("unknown parameter %q", name))
}
}
raw := strings.TrimSpace(q.Get("page"))
if raw == "" {
return out, domain.Invalid("page is required")
}
page := definition.CanonicalPage(raw)
if page == "" {
echoed := raw
if len(echoed) > maxEchoedPage {
echoed = echoed[:maxEchoedPage]
}
return out, domain.Invalid(fmt.Sprintf(
"Unsupported page: %s. Supported pages: %s.",
echoed, strings.Join(definition.SupportedPages, ", ")))
}
out.Page = page
out.Query = q.Get("query")
return out, nil
}
// Suggest ranks the page's readings for this caller.
//
// The role comes off the session-resolved identity, exactly as Server.authorize
// reads it, and an unrecognised role is offered nothing — the same deny-by-
// default the policy table applies. Nothing else about the caller is consulted:
// there is no branch here on organization, account type or anything a request
// could set.
//
// No error case beyond parsing. A page with no readings for this caller, and a
// query that matches none of them, both answer with an empty list — an empty
// result is an answer, not a failure.
func (s *SuggestionsService) Suggest(ident authctx.Identity, q SuggestionQuery) []owliver.Suggestion {
role, known := domain.ParseRole(ident.Role)
if !known {
return []owliver.Suggestion{}
}
return owliver.Suggest(q.Page, q.Query, role)
}

View File

@@ -150,7 +150,7 @@ func (s *Service) Get(ctx context.Context, ident authctx.Identity, id string) (d
// Create validates and inserts, returning the complete stored record. // Create validates and inserts, returning the complete stored record.
func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) { func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
clean, err := s.validate(body, true) clean, err := s.validate(ident, body, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -162,7 +162,7 @@ func (s *Service) Update(ctx context.Context, ident authctx.Identity, id string,
if !isUUID(id) { if !isUUID(id) {
return nil, domain.NotFound(s.res.Name, id) return nil, domain.NotFound(s.res.Name, id)
} }
clean, err := s.validate(patch, false) clean, err := s.validate(ident, patch, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -201,7 +201,11 @@ func (s *Service) Delete(ctx context.Context, ident authctx.Identity, id string)
// exactly how `interview_id`, `training_outline` and `score_breakdown` would // exactly how `interview_id`, `training_outline` and `score_breakdown` would
// have been lost: the frontend would have written them, the API would have // have been lost: the frontend would have written them, the API would have
// accepted the request, and the data would never have arrived. // accepted the request, and the data would never have arrived.
func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, error) { //
// The identity is a parameter because "the server will supply this column"
// is not a property of the column alone: a talent-only derivation supplies it
// for a talent caller and for nobody else. See serverSupplies.
func (s *Service) validate(ident authctx.Identity, in domain.Record, isCreate bool) (domain.Record, error) {
details := map[string]string{} details := map[string]string{}
out := make(domain.Record, len(in)) out := make(domain.Record, len(in))
@@ -237,7 +241,7 @@ func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, erro
if !col.Required { if !col.Required {
continue continue
} }
if s.serverSupplies(col.Name) { if s.serverSupplies(col.Name, ident) {
// The repository fills this from the session, so demanding it // The repository fills this from the session, so demanding it
// from the caller would reject a request the server is about to // from the caller would reject a request the server is about to
// complete correctly. evidence.worker_email is the live case. // complete correctly. evidence.worker_email is the live case.
@@ -262,13 +266,24 @@ func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, erro
} }
// serverSupplies reports whether a column is filled in from the authenticated // serverSupplies reports whether a column is filled in from the authenticated
// session rather than from the request body. // session rather than from the request body, FOR THIS CALLER.
func (s *Service) serverSupplies(name string) bool { //
// The caller matters. A TalentOnly derivation records who the row is ABOUT, and
// the repository fills it for a talent caller only — when an operator files an
// application or logs evidence on somebody else's behalf, the subject is not
// the operator, so nothing is derived and the value has to come from the body.
// Treating those columns as server-supplied for every role was how an operator
// creating a job application without an email got as far as SQL and came back
// with a not-null violation instead of the required-field message the contract
// promises. It must agree with repo.derivedValues, which decides the same thing
// on the write path.
func (s *Service) serverSupplies(name string, ident authctx.Identity) bool {
if s.res.Policy == nil { if s.res.Policy == nil {
return false return false
} }
isTalent := ident.Role == string(domain.RoleTalent)
for _, d := range s.res.Policy.Derived { for _, d := range s.res.Policy.Derived {
if d.Column == name { if d.Column == name && (!d.TalentOnly || isTalent) {
return true return true
} }
} }

View File

@@ -1,12 +1,20 @@
package service package service
import ( import (
"errors"
"net/url" "net/url"
"testing" "testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/domain"
) )
// The two callers validation distinguishes. Only the role is read.
var (
asOperator = authctx.Identity{Role: string(domain.RoleAdmin)}
asTalent = authctx.Identity{Role: string(domain.RoleTalent)}
)
// These exercise query parsing and validation without a database, so the // These exercise query parsing and validation without a database, so the
// contract's defaults are pinned even when PostgreSQL is not available. // contract's defaults are pinned even when PostgreSQL is not available.
@@ -139,24 +147,24 @@ func TestReservedParametersAreNotFilters(t *testing.T) {
func TestValidateRequiredAndUnknownAndEnum(t *testing.T) { func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
svc := New(resource(t, "job-postings"), nil) svc := New(resource(t, "job-postings"), nil)
if _, err := svc.validate(domain.Record{}, true); err == nil { if _, err := svc.validate(asOperator, domain.Record{}, true); err == nil {
t.Error("a create with no title was accepted") t.Error("a create with no title was accepted")
} }
if _, err := svc.validate(domain.Record{"title": " "}, true); err == nil { if _, err := svc.validate(asOperator, domain.Record{"title": " "}, true); err == nil {
t.Error("a blank title was accepted") t.Error("a blank title was accepted")
} }
if _, err := svc.validate(domain.Record{"title": "X", "bogus": 1}, true); err == nil { if _, err := svc.validate(asOperator, domain.Record{"title": "X", "bogus": 1}, true); err == nil {
t.Error("an unknown field was accepted") t.Error("an unknown field was accepted")
} }
if _, err := svc.validate(domain.Record{"title": "X", "status": "archived"}, true); err == nil { if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "archived"}, true); err == nil {
t.Error("an invalid enum value was accepted") t.Error("an invalid enum value was accepted")
} }
if _, err := svc.validate(domain.Record{"title": "X", "status": "active"}, true); err != nil { if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "active"}, true); err != nil {
t.Errorf("a valid payload was rejected: %v", err) t.Errorf("a valid payload was rejected: %v", err)
} }
// Server-owned fields are stripped, not rejected. // Server-owned fields are stripped, not rejected.
out, err := svc.validate(domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true) out, err := svc.validate(asOperator, domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true)
if err != nil { if err != nil {
t.Fatalf("server-owned fields caused a rejection: %v", err) t.Fatalf("server-owned fields caused a rejection: %v", err)
} }
@@ -168,11 +176,62 @@ func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
} }
// An update needs no required fields — it is a partial by definition. // An update needs no required fields — it is a partial by definition.
if _, err := svc.validate(domain.Record{"location": "Here"}, false); err != nil { if _, err := svc.validate(asOperator, domain.Record{"location": "Here"}, false); err != nil {
t.Errorf("a partial update was rejected: %v", err) t.Errorf("a partial update was rejected: %v", err)
} }
} }
// A talent-only derivation is only server-supplied for a talent caller.
//
// The column records who the row is ABOUT, and the repository fills it from the
// session for talent and for nobody else (repo.derivedValues). Validation has
// to agree: an operator filing an application for somebody else must be told
// `email` is required, rather than being let through to a not-null violation
// from SQL — and a talent caller must not be asked for the value the server is
// about to override anyway.
func TestValidateHonoursTalentOnlyDerivation(t *testing.T) {
apps := New(resource(t, "job-applications"), nil)
body := domain.Record{
"job_posting_id": "00000000-0000-0000-0000-000000000000",
"applicant_name": "Someone",
}
if _, err := apps.validate(asTalent, body, true); err != nil {
t.Errorf("a talent create without email was rejected: %v", err)
}
_, err := apps.validate(asOperator, body, true)
if err == nil {
t.Fatal("an operator create without email was accepted")
}
var apiErr *domain.Error
if !errors.As(err, &apiErr) {
t.Fatalf("error is not an API error: %v", err)
}
if apiErr.Details["email"] != "required" {
t.Errorf("details = %v, want email: required", apiErr.Details)
}
// evidence.worker_email is the same shape, and the case the original
// comment in serverSupplies was written for.
ev := New(resource(t, "evidence"), nil)
if _, err := ev.validate(asTalent, domain.Record{"type": "photo_identify"}, true); err != nil {
t.Errorf("a talent evidence create without worker_email was rejected: %v", err)
}
if _, err := ev.validate(asOperator, domain.Record{"type": "photo_identify"}, true); err == nil {
t.Error("an operator evidence create without worker_email was accepted")
}
// A derivation that is NOT talent-only stays server-supplied for everyone:
// user_activity records who acted, whoever that is.
act := New(resource(t, "user-activity"), nil)
for name, ident := range map[string]authctx.Identity{"operator": asOperator, "talent": asTalent} {
if _, err := act.validate(ident, domain.Record{"event_type": "x"}, true); err != nil {
t.Errorf("%s: an activity create was rejected: %v", name, err)
}
}
}
func TestIsUUID(t *testing.T) { func TestIsUUID(t *testing.T) {
valid := []string{ valid := []string{
"00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000",

View File

@@ -209,7 +209,7 @@ func (s *WorkflowService) Hire(ctx context.Context, ident authctx.Identity,
// happened without a log entry, or a log entry for a hire that rolled // happened without a log entry, or a log entry for a hire that rolled
// back, are both worse than neither. // back, are both worse than neither.
if _, err := repo.New(activity, tx).Insert(ctx, ident, domain.Record{ if _, err := repo.New(activity, tx).Insert(ctx, ident, domain.Record{
"event_type": "candidate_hired", "event_type": "hire_candidate",
"details": fmt.Sprintf("%s was hired", name), "details": fmt.Sprintf("%s was hired", name),
"application_id": applicationID, "application_id": applicationID,
"position_id": app["job_posting_id"], "position_id": app["job_posting_id"],
@@ -230,6 +230,11 @@ func (s *WorkflowService) Hire(ctx context.Context, ident authctx.Identity,
/* ── Assign ─────────────────────────────────────────────────────────────── */ /* ── Assign ─────────────────────────────────────────────────────────────── */
// AssignWorker is one worker in an assign request. // AssignWorker is one worker in an assign request.
//
// Three ways of naming the application this placement belongs to, in order of
// precedence: an id the caller already has, an `application` payload to find or
// file one from, and neither — a worker placed straight from the talent pool,
// who has no application and is not given an invented one.
type AssignWorker struct { type AssignWorker struct {
WorkerEmail string `json:"worker_email"` WorkerEmail string `json:"worker_email"`
WorkerName string `json:"worker_name"` WorkerName string `json:"worker_name"`
@@ -239,6 +244,17 @@ type AssignWorker struct {
WorkerProfileID *string `json:"worker_profile_id"` WorkerProfileID *string `json:"worker_profile_id"`
MatchScore *int `json:"match_score"` MatchScore *int `json:"match_score"`
Source string `json:"source"` Source string `json:"source"`
// Application is the application to attach this worker to when no
// ApplicationID is supplied: the same fields POST /job-applications takes.
//
// It exists because an application is what puts a person in the pipeline
// for a role, and every downstream step keys on it — the candidate record
// is addressed by it, and an AI interview takes one as its subject. A
// worker assigned without one is unreachable: nothing to open, and nobody
// to interview. The frontend was creating it in a second, untransacted
// request; this carries it into the same transaction as the assignment.
Application *domain.Record `json:"application"`
} }
// AssignRequest is the body of POST /job-postings/{id}/assignments. // AssignRequest is the body of POST /job-postings/{id}/assignments.
@@ -258,6 +274,11 @@ type AssignResult struct {
// request and one transaction. All-or-nothing across the whole batch: assigning // request and one transaction. All-or-nothing across the whole batch: assigning
// six workers and having the fourth fail should not leave three assigned, three // six workers and having the fourth fail should not leave three assigned, three
// not, and the caller unsure which. // not, and the caller unsure which.
//
// Per worker the flow is: settle the application (see settleApplication —
// patch the one named, find-or-file the one described, or neither), insert the
// assignment linked to whatever that produced, and write the audit entry. The
// application comes first because the assignment references it.
func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity, func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
postingID string, req AssignRequest) (*AssignResult, error) { postingID string, req AssignRequest) (*AssignResult, error) {
@@ -324,22 +345,43 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
assignRepo := repo.New(assignments, tx) assignRepo := repo.New(assignments, tx)
appRepo := repo.New(apps, tx) appRepo := repo.New(apps, tx)
// The application is filed through the service rather than the
// repository so that a payload the caller sent is validated exactly as
// POST /job-applications would validate it — unknown fields rejected,
// enums checked, required fields demanded — instead of reaching SQL and
// coming back as a constraint violation.
appSvc := New(apps, tx)
activityRepo := repo.New(activity, tx) activityRepo := repo.New(activity, tx)
for i, w := range req.Workers { for i, w := range req.Workers {
// The application is settled BEFORE the assignment is written,
// because the assignment carries the reference to it and a row
// cannot point at one that does not exist yet. An empty id means
// this worker legitimately has no application.
applicationID, err := s.settleApplication(ctx, ident, appRepo, appSvc, postingID, w)
if err != nil {
return annotate(err, i)
}
record := domain.Record{ record := domain.Record{
"job_posting_id": postingID, "job_posting_id": postingID,
"worker_email": w.WorkerEmail, "worker_email": w.WorkerEmail,
"worker_name": w.WorkerName, "worker_name": w.WorkerName,
"starts_at": w.StartsAt, "starts_at": w.StartsAt,
"status": "active", "status": "active",
"source": orDefault(w.Source, "manual"), // `owliver` is the column's own default (000001:511) and what
// the frontend sends. Substituting `manual` here made the API
// and the schema disagree about what an unspecified source
// means, so a row written through this endpoint and a row
// written through POST /assignments recorded different origins
// for the same action.
"source": orDefault(w.Source, "owliver"),
} }
if w.EndsAt != nil { if w.EndsAt != nil {
record["ends_at"] = *w.EndsAt record["ends_at"] = *w.EndsAt
} }
if w.ApplicationID != nil { if applicationID != "" {
record["application_id"] = *w.ApplicationID record["application_id"] = applicationID
} }
if w.WorkerProfileID != nil { if w.WorkerProfileID != nil {
record["worker_profile_id"] = *w.WorkerProfileID record["worker_profile_id"] = *w.WorkerProfileID
@@ -354,29 +396,14 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
} }
out.Assignments = append(out.Assignments, created) out.Assignments = append(out.Assignments, created)
// The application moves to `assigned` only when one was named. A
// worker can be placed without having applied — that is what the
// talent pool is for — and inventing an application for them would
// be worse than leaving the link absent.
if w.ApplicationID != nil {
updated, err := appRepo.Update(ctx, ident, *w.ApplicationID,
domain.Record{"status": "assigned"})
if err != nil {
return annotate(err, i)
}
if updated == nil {
return domain.NotFound("JobApplication", *w.ApplicationID)
}
}
entry := domain.Record{ entry := domain.Record{
"event_type": "worker_assigned", "event_type": "assign_employee",
"details": fmt.Sprintf("%s was assigned", orDefault(w.WorkerName, w.WorkerEmail)), "details": fmt.Sprintf("%s was assigned", orDefault(w.WorkerName, w.WorkerEmail)),
"position_id": postingID, "position_id": postingID,
"worker_email": w.WorkerEmail, "worker_email": w.WorkerEmail,
} }
if w.ApplicationID != nil { if applicationID != "" {
entry["application_id"] = *w.ApplicationID entry["application_id"] = applicationID
} }
if _, err := activityRepo.Insert(ctx, ident, entry); err != nil { if _, err := activityRepo.Insert(ctx, ident, entry); err != nil {
return annotate(err, i) return annotate(err, i)
@@ -391,6 +418,122 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
return out, nil return out, nil
} }
// settleApplication resolves the application an assignment belongs to and
// returns its id, or "" when this worker has none.
//
// Three cases, and the middle one is why this exists:
//
// - an id was supplied — move that application to `assigned`. Unchanged
// behaviour, and it still wins over any payload, because an id is a
// decision the caller has already made.
// - an `application` payload was supplied — find the application on this
// posting for this email, and move it to `assigned` if there is one or file
// it if there is not. (job_posting_id, email) is UNIQUE
// (job_applications_posting_email_key, 000001), so there is at most one to
// find and the insert cannot produce a second.
// - neither — nothing. A worker placed straight from the talent pool has no
// application, and inventing one for them would be worse than leaving the
// link absent.
//
// Everything here runs inside the caller's transaction, which is what makes the
// find-or-create safe: a concurrent assign of the same person to the same
// posting blocks on the unique index and is then reported as a conflict, rather
// than racing past the lookup and writing a duplicate.
func (s *WorkflowService) settleApplication(ctx context.Context, ident authctx.Identity,
appRepo *repo.Repo, appSvc *Service, postingID string, w AssignWorker) (string, error) {
if w.ApplicationID != nil {
updated, err := appRepo.Update(ctx, ident, *w.ApplicationID,
domain.Record{"status": "assigned"})
if err != nil {
return "", err
}
if updated == nil {
return "", domain.NotFound("JobApplication", *w.ApplicationID)
}
return *w.ApplicationID, nil
}
if w.Application == nil {
return "", nil
}
existing, err := findApplication(ctx, ident, appRepo, postingID, w.WorkerEmail)
if err != nil {
return "", err
}
if existing != nil {
id, _ := existing["id"].(string)
updated, err := appRepo.Update(ctx, ident, id, domain.Record{"status": "assigned"})
if err != nil {
return "", err
}
if updated == nil {
return "", domain.NotFound("JobApplication", id)
}
return id, nil
}
// The posting and the person come from the assignment, not from the
// payload. A body naming a different posting or a different email would
// file an application about somebody other than the worker being placed,
// and the lookup above would never find it again.
record := domain.Record{}
for k, v := range *w.Application {
record[k] = v
}
record["job_posting_id"] = postingID
record["email"] = w.WorkerEmail
if _, ok := record["applicant_name"]; !ok {
record["applicant_name"] = w.WorkerName
}
if _, ok := record["status"]; !ok {
record["status"] = "assigned"
}
created, err := appSvc.Create(ctx, ident, record)
if err != nil {
return "", err
}
id, _ := created["id"].(string)
return id, nil
}
// findApplication returns this posting's application for this email, or nil.
//
// The read goes through the repository so it carries the same organization
// scope and ownership predicate every other read does — an application in
// another tenant is not found, rather than found and then refused. The email
// column is citext, so the comparison is case-insensitive: the same equality
// the frontend performed with toLowerCase before it had this endpoint.
func findApplication(ctx context.Context, ident authctx.Identity, appRepo *repo.Repo,
postingID, email string) (domain.Record, error) {
res := appRepo.Resource()
postingCol, ok := res.Column("job_posting_id")
if !ok {
return nil, domain.Internal(errors.New("service: job_applications has no job_posting_id column"))
}
emailCol, ok := res.Column("email")
if !ok {
return nil, domain.Internal(errors.New("service: job_applications has no email column"))
}
page, err := appRepo.List(ctx, ident, domain.ListParams{
Limit: 1,
Filters: []domain.Filter{
{Column: postingCol, Values: []string{postingID}},
{Column: emailCol, Values: []string{email}},
},
})
if err != nil {
return nil, err
}
if len(page.Records) == 0 {
return nil, nil
}
return page.Records[0], nil
}
/* ── Helpers ────────────────────────────────────────────────────────────── */ /* ── Helpers ────────────────────────────────────────────────────────────── */
// pick takes the caller's value for a key when they supplied a usable one, and // pick takes the caller's value for a key when they supplied a usable one, and

View File

@@ -12,10 +12,13 @@ What lands here in later phases, once each is actually approved:
| File | Phase | Contents | | File | Phase | Contents |
| --- | --- | --- | | --- | --- | --- |
| `docker-compose.dev.yml` | 2 | PostgreSQL + pgvector, so the dev database stops being a machine-local install | | `docker-compose.dev.yml` | 2 | PostgreSQL + pgvector, so the dev database stops being a machine-local install |
| `docker-compose.dev.yml` (extended) | later | Redis, NATS, MinIO — each only when the phase that needs it starts | | `docker-compose.dev.yml` (extended) | later | Redis, MinIO — each only when the phase that needs it starts |
| `Dockerfile.api` | later | Multi-stage build for `go-api` | | `Dockerfile.api` | later | Multi-stage build for `go-api` |
| `Dockerfile.owliver` | later | The Python service | | `Dockerfile.owliver` | later | The Python service |
| `otel-collector.yaml` | later | OpenTelemetry collector config | | `otel-collector.yaml` | later | OpenTelemetry collector config |
NATS is deliberately absent from that table: it is not part of the target
architecture, and nothing here should reintroduce it.
Adding any of these before its phase would be speculative, so the directory Adding any of these before its phase would be speculative, so the directory
holds only this note for now. holds only this note for now.

View File

@@ -3,8 +3,9 @@
* *
* Runs the REAL frontend module graph through Vite — `import.meta.glob`, the * Runs the REAL frontend module graph through Vite — `import.meta.glob`, the
* `@/` alias and raw Markdown loading behave exactly as they do in the app, the * `@/` alias and raw Markdown loading behave exactly as they do in the app, the
* same technique `scripts/skill-check.mjs` uses. A mock of the registry would * same technique the frontend's own `scripts/skill-check.mjs` uses (that file
* reproduce none of the behaviour this file exists to capture. * lives in krow-demo, not here). A mock of the registry would reproduce none of
* the behaviour this file exists to capture.
* *
* Emits one JSON document: for every shipped definition and every adversarial * Emits one JSON document: for every shipped definition and every adversarial
* case, what the JS parser did with it. That document is the fixture the Go * case, what the JS parser did with it. That document is the fixture the Go