agent build

This commit is contained in:
2026-08-28 12:21:44 +05:30
parent b6f8655909
commit f7df96c973
138 changed files with 24164 additions and 207 deletions

View File

@@ -134,9 +134,33 @@ these would leave the shim with methods that 404. See §11 (D6).
`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`.
ask on this page?" for the Owliver panel, and it answers two different questions
depending on whether anything has been typed:
- **`query` present** — ranked against the static catalogue in `internal/owliver`.
No table is read, no transaction is opened and no model is called: the panel
issues one of these per keystroke, so the whole answer is a few string
comparisons.
- **`query` absent** — ranked against **the organization's actual state**, read
from PostgreSQL in one statement: how many positions are unfinished drafts,
how many active roles nobody has applied to, how many candidates are waiting
on a score, how many interviews carry a flag. This is one query per opened
panel, and it is what makes a suggestion react to the data — creating a
position changes what comes back next time it is asked.
Ranking here is by **tier first, count second**. Each reading declares how
much its subject matters when it is happening at all — a role nobody has
applied to outranks a queue of unscored candidates, which outranks a
headcount — and the count only orders readings inside a tier. Weight × count
would mean the largest pile always won, so a workspace with forty
applications and one abandoned role would be asked about the forty. A count
of zero scores nothing whatever its tier, so a page with nothing to report is
offered nothing rather than an urgent-sounding question about an empty set.
Only counts are read. Nothing that could name a record, a person or an id
reaches the ranking, and a talent caller's counts are never read at all: every
figure behind a highlight is organization-wide, and their rows are narrowed by
the policy table.
It is not on the public allowlist. Which readings exist depends on the caller's
role, so there is no anonymous answer to give.
@@ -153,8 +177,11 @@ 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.
`Unsupported page: {value}. Supported pages: {…}.` An absent or blank `query` is
**not** an error — it is a request for what the data itself suggests. A `query`
that was typed but is too short to rank (under two letters or digits) answers
with `[]` rather than falling back to the data: the user is mid-word, and
replacing what they are typing towards would flicker.
### Response
@@ -170,7 +197,18 @@ too-short `query` is **not** an error — there is simply nothing to rank yet.
```
`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.
and never an error. At most **three**, always. There is no `meta`: the list is
capped rather than paged.
`capability` names the section type the answer should be drawn as, and is
present only where the query asked for one — so a highlight, which nobody typed,
never carries it. `intent` is the frontend capability id the panel dispatches
on; it is not invented server-side, and
`TestIntentIDsAreFrontendCapabilities` holds the two vocabularies together.
An empty array with no query typed means the organization has nothing worth
raising — a workspace with no positions is asked nothing rather than asked three
questions about empty sets.
| Field | Meaning |
| --- | --- |

184
docs/deploy-b6f8655.md Normal file
View File

@@ -0,0 +1,184 @@
# Deploying to mcp.krowforce.com
Prepared and verified. **Not executed** — this machine has no Docker daemon, no
SSH access to the host and no registry credentials, and a production rollout is
not something to do without the operator watching.
---
## 1. What is running now
`954ba90` — or equivalently `cadea4b`, the merge commit whose tree is byte-identical.
Established from the outside, without credentials:
| Observation | Command | Conclusion |
| --- | --- | --- |
| Preflight echoes the origin and `Access-Control-Allow-Credentials` | `OPTIONS /api/v1/job-postings -H 'Origin: https://platform.krowforce.com'` → `204` | ≥ `954ba90` — that header was added there and is absent at `7d12ebe` |
| Session cookie is `SameSite=Lax` while a CORS allowlist is configured | `POST /api/v1/auth/logout` → `Set-Cookie: … Secure; SameSite=Lax` | < `b6f8655` — from that commit a configured allowlist forces `SameSite=None` |
| `routeOwliver` absent from `server.go` at `cadea4b` | `git show cadea4b:…/server.go \| grep s.route` | `GET /api/v1/owliver/suggestions` is not registered |
**52 endpoints deployed. 53 at `HEAD`.** The missing one is the Owliver
suggestions route, which is the 404 the frontend sees.
`/health` returns `{"status":"ok"}` — not `degraded` — so the remote schema is
present and not dirty.
## 2. What is being deployed
`b6f8655`, which is `HEAD` and is already `origin/main`. Nothing needs pushing.
**Plus one commit** prepared here — see §4. It is required: without it this
deploy silently removes the only CSRF protection the API has.
Local working-tree changes (the database-backed Owliver suggestion context) are
**not** part of this deploy and are not on any branch. They do not reach the
host, which builds from `origin/main`.
### Verified before shipping
| Check | Result |
| --- | --- |
| `go build ./...`, `go vet ./...` | clean |
| `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/api` | 17 MB static ELF, builds clean |
| `go test ./...` | 8/8 packages pass, against real migrated PostgreSQL |
| Migration delta `cadea4b → HEAD` | **none** — `git diff cadea4b HEAD -- migrations/` is empty |
| Config compatibility | no new validation; the new binary accepts a strict superset of what the host is configured with |
**No schema step.** This is a binary-only rollout.
## 3. What changes in behaviour
Beyond the new route, `b6f8655` fixes three things already broken in production:
- **`POST /api/v1/ai-interviews`** becomes transactional — it sets
`interview_id`, advances the application to `interview` and copies the score.
The deployed version writes only the interview, and the frontend deliberately
does not patch the application afterwards, so **completing an interview
currently leaves the candidate un-advanced with nothing to show it.**
- **`POST /api/v1/job-postings/{id}/assignments`** may now file an application
for a worker placed from the talent pool who never applied. It currently
cannot, so those assignments fail.
- **`serverSupplies`** now checks the caller's role. Previously a talent-only
derived column was treated as server-supplied for every role, so an
**operator** creating a job application without an email passed validation and
hit a NOT NULL violation — **a 500 where the contract promises 422.**
## 4. The required extra commit — cookie posture
`b6f8655` alone derives `SameSite` from whether a CORS allowlist is configured:
non-empty ⇒ `None`. On this host the allowlist *is* non-empty, so deploying it
as-is flips the session cookie from `Lax` to `None`.
**`SameSite` is the only CSRF protection this API has.** There is no CSRF token.
The derivation is wrong for this topology, because CORS and SameSite answer
different questions:
- **CORS** is about **origin**. `platform.krowforce.com` → `mcp.krowforce.com`
is cross-origin, so the allowlist is genuinely required. Confirmed live: that
origin returns `204` with the origin echoed; an unlisted origin returns `403`.
- **SameSite** is about **site**. Both share the registrable domain
`krowforce.com`, so they are **same-site** and a `Lax` cookie is already sent
on those requests.
So the correct configuration is *CORS on, `SameSite=Lax`* — a combination
`b6f8655` cannot express.
The commit makes an explicit `HTTP_COOKIE_SAMESITE` authoritative and leaves the
CORS-derived value as the default when it is unset. It also closes a trap:
`config.Load` has always parsed and validated that variable, and nothing read
it, so a deployment that set it saw it silently ignored.
```
internal/config/config.go unset is "" rather than defaulting to "lax"
internal/httpserver/auth.go explicit value wins; allowlist decides the default
internal/httpserver/samesite_test.go the full matrix, pinned
```
`docker-compose.yml` already passes `HTTP_COOKIE_SAMESITE: ${…:-lax}`, so a
compose deploy keeps `Lax` without any `.env` change.
> **Do not empty `HTTP_CORS_ORIGINS`.** It looks like a way to keep `Lax`
> without a code change, and it would take `platform.krowforce.com` offline —
> that frontend calls the API cross-origin from the browser.
## 5. Rollout
Migrations first, then the binary — the order `docker-compose.yml` already
encodes through `depends_on: service_completed_successfully`. There is nothing
to migrate this time, but the step is a no-op rather than something to skip.
```sh
# On the host, from the repository root
git fetch origin && git checkout b6f8655 # or the extra commit from §4
cd infrastructure
docker compose build api
docker compose up -d --no-deps migrate # exits 0, nothing to apply
docker compose up -d --no-deps api
docker compose ps # api healthy
docker compose logs -n 50 api # expect: "endpoints":53
```
`"endpoints":53` in the startup log is the single fastest confirmation that the
right binary is running.
## 6. Verification
```sh
KROW_EMAIL=… KROW_PASSWORD=… ./scripts/verify-deployment.sh
```
Read-only by default. Add `--write` to prove the write path reaches PostgreSQL;
it creates one posting with `status: draft`, which is invisible to talent — and
permanent, because `JobPosting` has no `DELETE`.
It checks, in order: `/health` and whether the schema reads `degraded`; login
and the issued cookie; the caller's `role` (a `talent` role explains almost every
403); `GET /owliver/suggestions` as the version discriminator; that
`/api/v1/positions` still `404`s; `GET /job-postings`; both suggestion modes and
the 3-item cap; and the `SameSite` attribute actually being served.
Exit status is the number of failures, so it can gate a rollout.
### The resource is `job-postings`
`/api/v1/positions` has never existed in this API. Every `/positions` in the
frontend is a React Router **UI** route. Two tests assert the phantom stays
absent — `TestThereIsNoPositionsResource` and the script's own check — so nobody
"fixes" a future 404 by adding a duplicate resource.
## 7. Rollback
The previous image is still on the host.
```sh
docker compose down api
git checkout cadea4b
docker compose build api && docker compose up -d --no-deps api
```
No schema change means rollback is clean: nothing to reverse, and the old binary
runs against the current schema unchanged.
## 8. Separately — the deployed frontends are not reaching the API
Found while verifying, out of scope for this deploy, and more severe than the 404.
`platform.krowforce.com` is built with
`VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1` — an absolute cross-origin
URL. The repository's own `.env` warns against this at length, and
`httpClient.js` has a guard for it that is compiled out of production builds, so
it fails silently.
`app.krowforce.com` is worse off: its origin is **not** on the backend's
allowlist (`403` at preflight), and its bundle carries neither `auth/login` nor
any reference to the API host.
Both hosts also serve their SPA for `/api/v1/*` — `GET /api/v1/me` on either
returns `index.html` with status `200`. The shipped `nginx.conf` has no `/api`
proxy at all, and `try_files $uri $uri/ /index.html` swallows every API path.
The fix is a `/api` and `/health` `proxy_pass` in the frontend's nginx plus a
rebuild with `VITE_API_BASE_URL=/api/v1`, which is what the same-origin design
assumes. That is a frontend deployment change and belongs in its own rollout.