133 KiB
Krow Backend — Complete Technical Summary
Audience: backend, frontend, AI/agent and DevOps engineers, and engineering leads.
Scope: what exists in the krow-backend repository today, how it got here, and where it is heading.
How this document was produced. Every factual claim below was read out of the
repository at /Users/apple/Krow/krow-backend as it currently stands — Go source,
go.mod, migrations, Makefile, README.md, docs/api-contract.md, tests and
fixtures — plus read-only queries against the local development database. Where an
earlier report or an in-repository comment disagrees with the code, the code is
treated as authoritative and the disagreement is recorded in §17.
Anything that could not be checked against this repository is marked "Not verified from current repository." Nothing here is inferred from a system that is not present in this checkout.
This is a technical history and current-state document. It does not certify phases.
1. Executive Summary
What the Krow backend is
Krow is a hiring and workforce platform: job postings, applications, AI screening interviews, staff records, worker profiles, assignments, attendance, a training library, evidence of work, and an audit log. On top of that sits an authoring system for Agents and Skills — Markdown documents with YAML frontmatter that describe what the product's assistant can be offered on each page.
krow-backend is the Go HTTP API and PostgreSQL database behind that product. The
Krow frontend lives in a separate repository (krow-demo) and is not vendored,
copied or modified here.
Why the backend was introduced
The frontend shipped first, as a demo. Its data layer was a client-side store:
src/api/store.js held arrays in memory, src/api/seed.js filled them at boot, and
src/api/base44Client.js was the seam every hook called through. That arrangement
has three properties that stop being acceptable the moment more than one person uses
the product:
- Data does not survive. It lives in the browser tab.
- There is no tenancy and no identity. Every viewer sees the same array.
- Nothing is enforceable. Any rule the client does not apply does not exist.
The backend exists to move persistence, identity, tenancy and enforcement to a
server, without rewriting the frontend above the transport seam. That constraint
is written into docs/api-contract.md as its acceptance criterion:
Replacing the transport inside
src/api/base44Client.js— and changing no other frontend file — must leave the application behaving identically.
Every design decision downstream follows from that sentence.
The transformation
OLD CURRENT
React React
| |
base44Client base44Client
| |
localStorage / mock data HTTP
|
Go API
|
PostgreSQL
The shape above the seam is unchanged. What moved is everything below it: the records now live in PostgreSQL, the organization a request runs as comes from a server-side session rather than from nowhere, and the rules about who may read or write what are applied in SQL and in the handler rather than in the browser.
The Agent / Skill path
Agents and Skills are authored as Markdown. The backend parses them, validates them against the same rules the frontend editor applies, projects a few queryable columns out of them, and stores the Markdown itself verbatim:
Markdown
|
Parser (internal/definition — YAML subset + frontmatter, ported from JS)
|
Validator (ValidateAgent / ValidateSkill — frontend messages, plus DB bounds)
|
PostgreSQL (agent_definitions / skill_definitions; markdown stored byte-for-byte)
|
CRUD API (/api/v1/agent-definitions, /api/v1/skill-definitions)
|
Runtime Loader (internal/runtime — loads by id or definition_id, re-parses)
|
Dependency Resolution (agent's `skills:` list resolved within tenant + ownership scope)
|
Execution Boundary (AgentExecutor / SkillExecutor interfaces)
What the runtime can and cannot do today
It can: load an authored agent or skill from the database by UUID or by
definition_id; re-parse and re-validate its Markdown; refuse it if its status makes
it ineligible (a draft or archived agent, an inactive skill); resolve an agent's
declared skill dependencies within the caller's organization and ownership scope,
preferring a personal definition over an organization one with the same
definition_id; deduplicate the resolved list; and hand the resolved agent to an
executor through a typed interface.
It cannot: execute anything. The only executor implementation in the repository
is UnavailableExecutor, which returns ErrExecutorUnavailable for both agents and
skills. There is no LLM client, no prompt assembly, no tool dispatch and no external
AI call anywhere in go-api/.
It is also not reachable over HTTP. No route in internal/httpserver references
the runtime package; a repository-wide search for runtime. outside the package
itself and its tests returns nothing. The runtime is an internal boundary exercised
only by its own test suite.
Current architectural direction
The backend is a layered, no-ORM Go service where the shape of every resource is generated from the live database schema and the rules about every resource are hand-written next to it. Authorization is deny-by-default. Tenancy and ownership are SQL predicates rather than post-fetch filters. The definition system treats the authored Markdown as the authoritative artefact and every column derived from it as a cache. The runtime establishes where AI execution will attach, without attaching it.
2. Development History
The repository documents its own work in phases, in migration headers, package
documentation and docs/api-contract.md. What follows describes what each phase
introduced and what of it is in the code today.
Phase 1 — Backend Foundation
What was built. The Go module, the PostgreSQL connection, the migration discipline and the initial schema.
- Go module —
github.com/krow/krow-backend/go-api,go 1.27. Three direct dependencies and nothing else:github.com/jackc/pgx/v5,golang.org/x/crypto,golang.org/x/term. No web framework, no ORM, no YAML library, no test framework. - Database access —
internal/dbowns apgxpool.Pool.db.Opendeliberately acquires and pings once at startup, becausepgxpool.Newis lazy and would otherwise defer a misconfiguration to the first request. - Configuration —
internal/configloads from the environment with typed defaults and avalidate()pass that refuses: an unknownAPP_ENV; a port out of range; aDATABASE_SCHEMAthat is a PostgreSQL system schema or starts withpg_;MIN_IDLE_CONNSaboveMAX_OPEN_CONNS;DATABASE_SSLMODE=disablewhenAPP_ENV=production; and a CORS entry of*or one without a scheme. - Health —
GET /health, anddb.Checkbehind it. - Migrations —
migrations/is the source of truth for the schema. Nothing ingo-api/issues DDL, and no ORM generates any. TheMakefilewraps thegolang-migrateCLI (README records 4.19.1).
Implementation decisions worth carrying forward.
uuidprimary keys plus a nullable uniquelegacy_id text, so the frontend's existing string ids (jobposting_m1a2b3c001) survive as data without becoming the addressable id.timestamptzthroughout;created_date/updated_datekeep the frontend's own names, because the frontend windows every collection oncreated_dateand its default sort string is-created_date.- Native enums for closed vocabularies,
text+CHECKwhere the set is still moving. - Email columns are
citext, and stay populated even where a UUID foreign key also exists, because the frontend joins workers by email today. - Guards against destructive change: there is deliberately no
make migrate-droptarget;make migrate-downrefuses unlessAPP_ENV=development; down migrations name every object they drop, with noDROP SCHEMAand noCASCADEon a schema.
Phase 2A — Architecture Decisions
Four questions were raised, investigated against the frontend, and recorded in
docs/api-contract.md §11 as deferred with reasons. All four are still recorded
there as deferred.
| Ref | Question | Recorded outcome |
|---|---|---|
| D2 | Is company a real entity? |
It is free text on job_postings, displayed and read through a fallback chain, but never grouped by id, joined, or given a route. Kept as company text NOT NULL DEFAULT ''. No clients table, no endpoint. |
| D3 | Are assigned / rejected pipeline stages? |
The frontend's STAGE_ORDER excludes both from every funnel count via indexOf(...) >= from. The enum carries all seven values; the API stores and returns status verbatim and never filters, reinterprets or normalises it. The funnel exclusion is recorded as a live frontend behaviour that Phase 2 does not touch. |
| D6 | Do the unmounted pages come back? | Fourteen page files plus a layout are imported by App.jsx and mounted on no route. Three operations are reachable only through them. All three endpoints were included anyway and marked unreachable-today, because the calling code exists and excluding them would make the transport shim need conditional methods. |
| U1 | Where do shift records come from? | ShiftRecord has exactly one frontend seam operation: .list('-created_date', 500). No create, no update, no delete. The contract specifies GET /api/v1/shift-records only — no POST — because there is no call site to derive a write contract from. |
Organization / tenant modelling. organizations and user_preferences are the
two tables in migration 000001 that do not correspond to an entity the frontend
reads through base44Client.js; both are documented in the migration itself. Every
tenant-scoped table carries org_id. Two tables — courses and learning_paths —
are organization-nullable: a NULL org_id means the shared platform library,
visible to every tenant.
Phase 2B — API Contract
docs/api-contract.md (1,060 lines) was written before implementation, derived
entirely from the frontend repository and the Phase 1 schema. Its stated rule: every
endpoint exists because a call site exists, and every semantic was read out of
src/api/store.js rather than designed.
- Operation matrix. An operation with no call site gets no endpoint.
DELETE /job-postings/{id}andPOST /shift-recordsare absent for that reason, and the router answers 405 for them because the path pattern exists under another method. - REST shape. Base path
/api/v1; kebab-case plural resource names, with mass nouns staying singular (/staff,/evidence,/user-activity);PATCHwith shallow merge and noPUT. - Field naming. snake_case, identical to the frontend's own field names — no
renaming, no camelCase conversion. The single documented exception is
/me/preferences. - Envelope. Responses are wrapped in
{"data": …}, withmetaon collections. The frontend's methods return bare values; the shim unwraps with one.data. What the envelope buys ismeta.total, which the bare shape has nowhere to put. - Filtering (§6). Two operators only: equality for a single value, membership for a repeated parameter. Every non-reserved query parameter is a field filter.
- Sorting (§7).
NULLS LASTin both directions, because the frontend's comparator returns before it can place a null; and, idappended to everyORDER BY, because JavaScript's sort is not stable in the way the UI relies on. - Pagination (§8).
limit/offset, with per-endpoint defaults taken from the literal arguments at each frontend call site. - Errors (§5). A single envelope shape with a code, a message and a details map.
Phase 2C — Backend Implementation
Layers. internal/domain (descriptors and errors) → internal/service
(parsing, validation, contract semantics) → internal/repo (all SQL) → pgx.
Generated descriptors. internal/domain/resources_gen.go is produced by
scripts/gen_resources.py (make gen-resources), which reads column names, types,
enum values and nullability out of information_schema. The hand-maintained part is
the per-resource metadata — path, default sort, default limit, supported operations,
required fields — which comes from the contract. Column definitions therefore cannot
drift from the migrations.
One query builder, not fourteen repositories. internal/repo/repo.go builds
every statement from a *domain.Resource: column lists are explicit, every value is
a bind parameter cast to its declared type, and no identifier ever comes from user
input — a filter or sort name is resolved to a real *domain.Column before any SQL
is assembled. The contract's awkward semantics are then implemented once and apply
identically everywhere.
Seed system. seed/fixtures/seed.json is generated by executing the frontend's
src/api/seed.js through Vite and serialising what it exports, so ids, dates,
numbers and enum values arrive exactly as the demo has them with no transcription
step. internal/seeder loads it inside a single transaction with explicit upserts.
Shift records are the exception and are generated by a Go port of
attendanceSeed.js — see §6.
Bugs and gaps this phase exposed, all recorded in docs/api-contract.md §13:
- Three columns had no home (§13.1).
job_applications.interview_id,courses.training_outlineandworker_profiles.score_breakdownare written or read by the frontend and were absent from migration 000001. Migration 000001 was not edited; the columns were added in 000002.interview_idis deliberately not a foreign key, because a seeded application references an interview id for which no record exists, and nulling it would have transformed source data. - A constraint that rejected real data (§13.2).
job_applications_screened_consistentfrom 000001 rejected 9 of 24 seeded applications — precisely the 9 AI-scored ones.screened_atis never read or written by the frontend; the constraint came from a blueprint rather than from evidence. Dropped in 000003; the column is retained, nullable and unused. All 53 CHECK constraints were then evaluated against all 245 seeded records; the other 52 hold. - Unknown fields are rejected, not ignored (§13.5). A body field that maps to no
column answers 422 with the field named in
details. The reasoning is explicit: silently ignoring them is exactly how the three columns above would have been lost. Server-owned fields (id,org_id,created_date,updated_date,legacy_id) remain ignored rather than rejected. - Three deliberate divergences from
store.js(§13.4): email matching is case-insensitive because the columns arecitext;updated_dateis always populated because the column isNOT NULLand the seeder sets it tocreated_date; and_order, a positional index the frontend used while building its course list, is dropped.
Phase 2D — Frontend Transport Integration
This phase belongs to the frontend repository, which is not present in this checkout. What is verifiable here is the backend side of the seam:
- CORS (
internal/httpserver/cors.go). Origins are matched exactly against an allowlist and echoed back one at a time;*is rejected at config validation. A request whoseOriginis not on the list is served normally with no CORS headers — the API does not refuse it, the browser simply will not hand the response to the page. That distinction is deliberate so that curl, health checkers and server-to-server callers, which send noOrigin, are unaffected. With an empty allowlist the middleware is not installed at all. - Development defaults. With
APP_ENV=developmentandHTTP_CORS_ORIGINSunset, the allowlist defaults tohttp://localhost:5173,http://127.0.0.1:5173and thevite previewport — both hostnames, because a browser treats them as different origins. Unset anywhere else it defaults to empty, meaning same-origin only. - JSON everywhere.
net/http's own plain-text 404 and 405 replies are intercepted and rewritten into the error envelope, so every response from the API is JSON.
The frontend files named in the contract — base44Client.js, krowHooks.js,
store.js — appear only as references inside this repository's documentation and
comments. httpClient.js and any VITE_API_* environment variable are not
referenced anywhere in this repository: Not verified from current repository.
Known frontend/backend mismatches recorded in the contract §12: multi-record
writes are not transactional; collection caps truncate silently; all aggregation is
client-side; search is browser-side substring matching; DELETE on a missing record
returns 200. See §17.
Phase 3 — Authentication & Authorization
Split across several steps in the repository's own narrative; what exists today is described in §8. Chronologically:
3B — schema and primitives. Migration 000004 adds two facts and nothing
else: a globally unique index on users.email (because the login form supplies no
organization, so an email must resolve to exactly one user), and a sessions table.
The migration explicitly declines to add roles, permissions, organization_members,
credentials or refresh_tokens tables. internal/auth was written in the same step
and deliberately knows nothing about HTTP: password hashing (password.go), token
generation and hashing (token.go), the session lifecycle (session.go), the
PostgreSQL stores (store.go, users.go) and credential verification
(credentials.go).
3C — the HTTP surface. POST /api/v1/auth/login, POST /api/v1/auth/logout,
and the authenticate middleware. This step removed devOrgMiddleware, which had
put a fixed organization on every request with no credential behind it. Because the
organization had always been threaded explicitly through the service and repository
boundaries rather than defaulted inside the SQL, that swap changed one line and
nothing below it.
3D — authorization. internal/domain/policy.go — a hand-written table, kept
separate from the generated descriptors precisely so that regenerating descriptors
can never silently drop an access rule, and a new column can never grant anyone
anything by accident. The handler gate is Server.authorize in
internal/httpserver/api.go; the row predicates are in builder.ownership in
internal/repo/repo.go. docs/api-contract.md §9A documents the resulting contract.
Health information exposure reduction. GET /health is public, so its body was
reduced to a single field: {"status": "ok" | "degraded" | "unavailable"}. The
PostgreSQL version, database name, schema name, applied migration version, table
count, connection error text, environment and uptime were removed from the response
and moved to the server log — debug when healthy, warn otherwise. db.Check
itself is unchanged and still gathers all of it.
Future authentication direction is discussed in §18 and is separated there from what the code does.
Phase 4A — Agent & Skill Architecture
Agents and Skills are configuration written as Markdown with YAML frontmatter. Three tiers exist, and the reasoning for keeping them apart is recorded in migration 000005:
| Tier | Where it lives | Rows in this database |
|---|---|---|
| shipped | src/agents/**/*.md, src/skills/**/*.md in the frontend repo, versioned in Git, bundled at build time |
none — they are code, and putting them in a table would trade git log, code review and atomic deploy for nothing |
| organization | authored in the app, shared across one tenant | agent_definitions / skill_definitions, visibility = 'organization' |
| personal | authored in the app, private to one user | agent_definitions / skill_definitions, visibility = 'personal' |
Before this work, the last two lived in user_preferences.extra — a jsonb blob with
no owner, no tenancy, no size bound, no server-side validation and no query surface,
returned in full by GET /api/v1/me on every page load.
Phase 4B — Definition Contract
A definition is a Markdown document whose leading fenced block is YAML frontmatter
and whose remainder is the body. The parser contract is stated in
internal/definition/definition.go:
- The document layer — byte-order mark, line endings, leading blank lines, fence
recognition, body extraction (
frontmatter.go). - The YAML subset — block maps and sequences, scalars, quoting, comments
(
yaml.go). - Definition-level normalization and validation — id, name, description, status, version, pages, icons, reasoning, permissions, starters, knowledge, subagents.
Those cover every column migration 000005 projects out of a definition:
definition_id, status, version, name, description, pages.
Field-by-field detail for both kinds is in §9.
Phase 4C — Definition Database
Migration 000005 creates agent_definitions and skill_definitions — two
tables, deliberately, not one. The reasoning is recorded in the migration: agents
have draft | published | archived plus a monotonic integer version; skills have
active | inactive and no version at all, because the frontend has no notion of
a skill version. One table would need a union CHECK permitting "version 5, status
inactive", and a version column forever equal to 1 for half the rows.
The migration also records what it deliberately does not create:
definition_versions (nothing retains prior Markdown and no rollback feature exists
to serve), definition_permissions (the permissions: block stays inside the
Markdown, parsed and unenforced), agent_skills / agent_subagents (the skills:
list names ids in a namespace that includes shipped definitions, which have no rows
here, so a join table would need foreign keys to rows that do not exist),
agent_knowledge, and conversations.
Schema detail is in §5.
Phase 4D — Parser Compatibility
Two parsers read the same definition: the JavaScript in the frontend's
src/lib/skills and src/lib/agents, and the Go in internal/definition. If they
disagree, one of two silent failures occurs: a definition the editor accepts and the
API rejects looks valid while it is being written and fails when it is saved; or a
definition the API accepts and the editor rejects is stored and then cannot be
rendered by the product that owns it.
The Go side is therefore a port, not an independent implementation, and compatibility is enforced by replay rather than by description. Detail is in §10.
Phase 4E — Agent & Skill CRUD
Ten HTTP routes — list, create, get, update, delete for each of agents and skills —
plus internal/service/definitions.go and internal/repo/definitions.go. Detail is
in §11.
Phase 4F — Runtime Boundary
internal/runtime introduces the runtime representations (runtime.Agent,
runtime.Skill), the execution value types (ExecutionInput, ExecutionResult,
RuntimeError), a Loader that reads a stored definition and re-parses it, status
eligibility rules, dependency resolution with personal-over-organization shadowing,
the AgentExecutor / SkillExecutor interfaces, an Engine that ties them
together, and UnavailableExecutor.
The current runtime establishes the execution boundary. UnavailableExecutor
returns ErrExecutorUnavailable from both ExecuteAgent and ExecuteSkill, and
performs no AI execution of any kind. Detail is in §12.
3. Current Architecture
Request path
React (krow-demo — separate repository)
|
base44Client.js the one frontend file allowed to change
|
HTTP (JSON, /api/v1, cookie-bearing)
|
===================== krow-backend =====================
|
Go HTTP server net/http.Server + ServeMux
|
requestLogger method, path, status, duration
|
cors installed only when an allowlist is configured
|
recoverer a panic becomes a logged 500, not a dropped connection
|
authenticate cookie -> session row -> user row -> Identity + OrgID
|
jsonErrors rewrites the mux's plain-text 404/405 into the envelope
|
HTTP handlers role gate (403), body decode, path values
|
Service layer query parsing, validation, contract semantics
|
Repository layer all SQL; org + ownership predicates; bind parameters only
|
pgx / pgxpool
|
PostgreSQL
Definition and runtime path
Agent/Skill Markdown authored in the app, submitted as a JSON string
|
Parser internal/definition — frontmatter + YAML subset
|
Validator ValidateAgent / ValidateSkill
|
Definition Projection id, status, version, name, description, pages
|
PostgreSQL agent_definitions / skill_definitions
markdown stored verbatim; columns derived from it
|
Definition CRUD /api/v1/agent-definitions, /api/v1/skill-definitions
|
Runtime Loader internal/runtime — load by uuid or definition_id,
re-parse, re-validate
|
Dependency Resolution agent.Skills -> LoadSkill each, tenant + ownership
scoped, personal shadows organization, deduplicated
|
Executor Boundary AgentExecutor / SkillExecutor interfaces
(only UnavailableExecutor exists)
Layer responsibilities
| Layer | Package | Owns | Deliberately does not own |
|---|---|---|---|
| Entrypoint | cmd/api |
config load, pool, logger, signal handling, graceful shutdown, session sweeper goroutine | routing, business rules |
| Config | internal/config |
environment loading, typed defaults, validation | secrets (nothing is baked in) |
| Database | internal/db |
pool lifecycle, startup ping, Check for /health |
any DDL |
| HTTP | internal/httpserver |
routing, middleware chain, role gate, response envelopes, cookies, rate limiting | SQL |
| Auth primitives | internal/auth |
argon2id, token generation/hashing, session lifecycle, credential verification | HTTP, cookies, logging (nothing in the package logs) |
| Identity context | internal/authctx |
the authenticated Identity on the request context |
any setter that takes a user id from a client |
| Org context | internal/orgctx |
the organization a request runs as | deciding which organization that is |
| Service | internal/service |
query-string parsing, body validation, contract semantics | statement construction |
| Domain | internal/domain |
resource descriptors (generated), the authorization policy table (hand-written), error constructors, record types | I/O |
| Repository | internal/repo |
every SQL statement, organization scope, ownership predicates, derived columns | deciding whether a role may act |
| Definitions | internal/definition |
frontmatter, the YAML subset, agent/skill normalization and validation | storage, HTTP, and the ui:/owliver: blocks |
| Runtime | internal/runtime |
loading, eligibility, dependency resolution, the executor interfaces | executing anything |
| Seeder | internal/seeder |
fixture loading, deterministic ids, upsert, shift generation and pruning | schema changes |
| Test harness | internal/testutil |
a disposable migrated + seeded database per test process | touching the development database |
Where authorization happens, and why it is split. The role check runs in the
handler before any query, so it always answers 403 and can never reveal whether a
row exists. Organization scope and ownership are SQL predicates in the WHERE
clause, so a row outside them is simply absent and answers 404. A caller cannot
distinguish "exists and is not yours" from "does not exist". Because the predicate
is in SQL, count(*) runs over the same clause — so meta.total for a talent
caller is their own count, not the organization's.
4. Technology Stack
Current implementation — verified from this repository
| Concern | What is used | Evidence |
|---|---|---|
| Language | Go, module directive go 1.27; local toolchain go1.27.0 darwin/arm64 |
go-api/go.mod, go version |
| PostgreSQL driver | github.com/jackc/pgx/v5 v5.10.0 (with pgxpool) |
go-api/go.mod |
| Password hashing | golang.org/x/crypto v0.42.0 (argon2) |
go-api/go.mod, internal/auth/password.go |
| Terminal input | golang.org/x/term v0.35.0 (hidden password prompt) |
go-api/go.mod, cmd/setpassword |
| Indirect deps | pgpassfile v1.0.0, pgservicefile, puddle/v2 v2.2.2, x/sync v0.17.0, x/sys v0.37.0, x/text v0.29.0 |
go-api/go.mod |
| HTTP server | Go standard library only — net/http.Server and http.ServeMux with method+pattern routes ("GET /api/v1/job-postings", "{id}" path values). No third-party router or framework. |
internal/httpserver/server.go, api.go |
| Logging | log/slog, JSON handler to stdout, level from LOG_LEVEL |
cmd/api/main.go |
| Migrations | golang-migrate CLI, wrapped by the Makefile; -seq numbering |
Makefile, README.md (records version 4.19.1) |
| PostgreSQL | 18.6 (Homebrew) on the local development machine | read-only SHOW server_version |
| Applied migration version | 5, not dirty, in the local development database | read-only SELECT version, dirty FROM schema_migrations |
| Extensions installed | citext, plpgsql. pgvector is not installed. |
read-only SELECT extname FROM pg_extension |
| Testing | Go's own testing package. No assertion library, no mocking framework, no test containers. Database-backed tests build a disposable database per test process. |
internal/testutil/db.go |
| Parser | Hand-written, no YAML dependency — a port of the frontend's yaml.js. Conformance is asserted against a captured JS oracle fixture. |
internal/definition/yaml.go, conformance_test.go |
| Runtime | Go, in-process, no external calls. Executor is an interface with one stub implementation. | internal/runtime |
| Code generation | scripts/gen_resources.py (Python 3, shells out to psql) |
Makefile target gen-resources |
| Oracle capture | scripts/oracle.mjs + scripts/cases.mjs (Node, runs the frontend module graph through Vite) |
scripts/ |
Frontend transport relationship. The frontend repository is not part of this
checkout. This backend serves the contract in docs/api-contract.md; the frontend
consumes it through base44Client.js. No frontend source is vendored, copied or
modified here.
Future / planned direction — not implemented
Nothing in this list is present in the repository. See §18 for detail and §17 for what the repository does and does not say about each.
- Owliver (the planned Python service), LangGraph, or any orchestration layer
- Any LLM provider client
- RAG, embeddings,
pgvector - FastMCP or any tool-execution layer
- Redis, or any shared cache/rate-limit store
- Docker images or compose files
- Object storage
- OpenTelemetry or any tracing/metrics exporter
5. PostgreSQL & Database Architecture
Database and schema
- Local development database name and credentials come from environment variables;
.envis gitignored and.env.examplecontains only placeholders and safe local defaults. No password, key or token appears in any tracked file. - The application owns exactly one schema, defaulting to
public, andconfig.validate()refuses to point it at a PostgreSQL system schema. migrations/holds five up/down pairs. The local development database reports applied version 5, not dirty.
Migrations
| File | Introduces |
|---|---|
000001_initial_schema |
17 tables, 16 enum types, the citext extension, and the index and constraint set |
000002_application_interview_id |
job_applications.interview_id, courses.training_outline, worker_profiles.score_breakdown |
000003_drop_screened_consistent_check |
drops job_applications_screened_consistent; keeps the unused screened_at column |
000004_auth_sessions |
global unique index on users.email; the sessions table |
000005_agent_skill_definitions |
agent_definitions, skill_definitions |
Every migration has a matching down file, and a test asserts that (TestEveryMigrationHasADownFile). Two further tests assert that 000004 and 000005 are reversible, and one asserts that 000005 adds exactly two tables and no others.
Migration rules recorded in README.md: files are the source of truth; an
applied migration is immutable (write the next one instead); staging and production
run the same files as a discrete deploy step before the new binary rolls out,
which means every migration must be backwards-compatible with the currently-running
version — expand, migrate, contract, never in one migration.
Tables present in the local public schema
Twenty application tables plus schema_migrations (21 base tables, verified
read-only):
organizations users user_preferences sessions
job_postings job_applications ai_interviews staff
worker_profiles assignments shift_records evidence
user_activity courses learning_paths role_categories
certifications badges agent_definitions skill_definitions
Important tables in plain terms
| Table | What it holds | Notable |
|---|---|---|
organizations |
the tenant | every tenant-scoped table references it |
users |
people who can sign in | role (admin/employer/talent) is the authorization field; account_type is a display attribute; password_hash is nullable and NULL until set; email is citext and globally unique since 000004 |
sessions |
server-side sessions | stores only SHA-256 of the token; two expiries; ON DELETE CASCADE from users |
user_preferences |
per-user settings | three real columns plus an extra jsonb blob |
job_postings |
the shop window | status enum draft/active/paused/closed; created_by is server-owned; GIN index on skill_requirements |
job_applications |
applications | status enum carries seven values; email is citext; interview_id is a deliberate soft reference with no FK |
ai_interviews |
screening interview results | references an application; ownership is by reference, not by column |
worker_profiles |
a worker's own profile | user_id is server-owned for talent callers; GIN index on completed_courses |
staff |
the employment record of the workforce | operator-facing: endorsements, review dates, reviewer names |
assignments |
who is on which position | a GiST index over the assignment period |
shift_records |
attendance | read-only over the API; the seeder owns these rows |
evidence |
proof of work | submitted by a worker, verified by the organization |
user_activity |
the audit log | append-only by schema; four server-derived identity columns |
courses, learning_paths |
the training library | organization-nullable: NULL org_id is the shared platform library |
badges |
badge definitions | the table exists; no endpoint reads it (see §17) |
agent_definitions, skill_definitions |
authored definitions | see below |
Conventions
uuidprimary keys (gen_random_uuid()), plus a nullable uniquelegacy_id text.timestamptzthroughout;created_date/updated_datekeep their frontend names.- Native enums for closed vocabularies (16 types in 000001),
text+CHECKwhere the set is still moving. The definition tables usetext+CHECKfor bothvisibilityandstatus. - Email columns are
citext. - 53 CHECK constraints were introduced in 000001; one was dropped in 000003.
Ownership and visibility model on the definition tables
agent_definitions / skill_definitions
org_id NOT NULL, even for a personal definition
(a user belongs to exactly one organization, so the org
predicate applies to every read whether ownership does or not)
visibility 'personal' | 'organization' CHECK
owner_user_id set if and only if visibility='personal' CHECK
((visibility = 'personal') = (owner_user_id IS NOT NULL))
ON DELETE CASCADE — a personal definition dies with its owner
created_by always the author, for attribution
ON DELETE SET NULL — a shared definition survives its author
Uniqueness is per tier, by two partial unique indexes — not one global unique constraint, because shadowing by id is the point:
..._personal_key UNIQUE (owner_user_id, definition_id) WHERE visibility='personal'
..._org_key UNIQUE (org_id, definition_id) WHERE visibility='organization'
Other constraints on both tables: definition_id ~ '^[a-z0-9][a-z0-9-]*$';
length(markdown) BETWEEN 1 AND 65536; agent status IN ('draft','published','archived')
and version >= 1; skill status IN ('active','inactive') and no version column.
Indexes: (org_id, visibility) for tier listing (which also covers the org_id FK),
(owner_user_id) for a user's own definitions and the cascade check, and a partial
index for the live rows — (org_id, visibility) WHERE status='published' for agents,
and the equivalent active-only index for skills. There is deliberately no index
on created_by, because it is attribution only.
Verified seed counts
Counted directly from seed/fixtures/seed.json in this repository:
| Entity | Records in fixture |
|---|---|
| Course | 40 |
| JobApplication | 24 |
| UserActivity | 15 |
| RoleCategory | 9 |
| WorkerProfile | 9 |
| JobPosting | 8 |
| Certification | 8 |
| AIInterview | 4 |
| Badge | 4 |
| Staff | 3 |
| Evidence | 3 |
| LearningPath | 2 |
| User | 1 |
| Assignment | 0 (empty in the source by design) |
ShiftRecord is not in the fixture — it is generated. docs/api-contract.md §13.7
records the generated figure as 115 records (2 absent, 1 no-show, 5 late, 107
present) with the qualifier that the set is anchored to the day the seeder runs.
docs/api-contract.md §13.7 also records that "6 job postings" in an earlier brief
was the active count; the fixture carries 8 (6 active, 1 paused, 1 closed).
6. Seed & Data Strategy
Source
seed/fixtures/seed.json is generated, not written: the frontend's
src/api/seed.js is executed through Vite and what it exports is serialised. The
reason is stated in internal/seeder/seeder.go: ids, dates, numbers and enum values
then arrive exactly as the demo has them, with no transcription step and nothing to
drift.
Shift records — generated, not snapshotted
internal/seeder/shifts.go is a Go port of the frontend's attendanceSeed.js.
Shift records are the one collection whose dates are anchored to now rather than to
a fixed calendar: the frontend's dataResolver.inPeriod windows every collection on
created_date, so a frozen snapshot would read as permanently empty a fortnight
later, and the attendance and overtime features would have nothing to show.
Nothing in the generator is random. The distribution is deterministic given the date the seeder runs, over a 56-day window, across a three-person roster designed so the demo has a control, an anomaly and a trend:
| Person | Pattern |
|---|---|
| Marco Rivera | the control — reliable, weekend event overtime |
| Marcus Williams | attendance degrading over the last fortnight (the anomaly) |
| Chef Antoine Dubois | present throughout, overtime climbing week on week (the trend) |
UUID strategy
seeder.DeterministicUUID derives a UUID v5 from the source id over a fixed
namespace (krow-seed-v1...), implemented with SHA-1 and the RFC 4122 version and
variant bits. Changing the namespace re-keys the entire dataset, so it is a constant
rather than configuration. Foreign key values in the fixture are run through the same
derivation, so a reference resolves to the row it named.
Idempotency
Explicit upsert inside one transaction. Every record's primary key is derived
from its source id, so re-running targets exactly the same rows and
ON CONFLICT (id) DO UPDATE restores each one to its seeded values. Consequences,
stated in the package documentation:
- Records created through the API survive a re-seed — nothing is deleted.
- A column the fixture does not carry is left as it is.
- Entities are inserted in a fixed order chosen so every foreign key is satisfied by the time it is referenced.
The one exception: pruning
Shift records are also pruned (pruneShiftRecords). Upsert alone cannot converge
a rolling window: yesterday's generated set and today's overlap but are not the same
set, so without a prune, re-seeding on a later day would leave stale rows behind. The
prune is scoped to the seeded organization, and the number pruned is reported in the
seeder's result rather than being silent — a delete during a seed should not be
something you have to read the source to discover.
Four tests cover exactly this behaviour: TestReseedOnALaterDayLeavesNoStaleShifts,
TestReseedIsConvergentAcrossAWeek, TestReseedSameInstantPrunesNothing, and
TestPruneIsScopedToTheSeededOrganization.
Limitations
- The demo user's
password_hashis NULL after seeding. Nothing in the fixture, the migrations or this repository contains, generates or defaults a password. A password enters the system only throughcmd/setpassword, typed by a person or piped on stdin. Assignmentis empty in the source and stays empty.- Attendance and overtime are among the richest features in the product and will be empty in any real deployment until a rostering source exists — an import, an integration, or a scheduling UI, none of which exist. This is recorded as U1 in the contract.
7. API Architecture
Conventions
| Aspect | Behaviour |
|---|---|
| Base path | /api/v1 |
| Resource naming | kebab-case plural; mass nouns singular (/staff, /evidence, /user-activity) |
| Identifiers | uuid in the path; the frontend's original string ids live in legacy_id |
| Content type | application/json; charset=utf-8 both ways |
| Field naming | snake_case, identical to the frontend's names. The single exception is /me/preferences, which is camelCase |
| Dates | ISO 8601 with offset, rendered by the database as YYYY-MM-DDTHH:MM:SS.mmmZ |
| Partial updates | PATCH, shallow merge. There is no PUT |
| Reserved query params | sort, limit, offset. Every other parameter is a field filter |
| Request body cap | 4 MiB (maxBodyBytes) |
| Cache headers | every JSON response sets Cache-Control: no-store |
Response envelopes
Success — a record:
{ "data": { "id": "…", "title": "…", "created_date": "…" } }
Success — a collection:
{
"data": [ … ],
"meta": { "total": 24, "limit": 100, "offset": 0, "returned": 24, "truncated": false }
}
meta.truncated is total > offset + returned. It exists so that the silent
truncation recorded in contract §12.2 is fixable without another contract change.
Failure:
{ "error": { "code": "validation_failed", "message": "…", "details": { "field": "…" } } }
details is always present, as an object, even when empty.
Filtering, sorting, pagination
- Filtering — a single value is equality (
?status=hired); a repeated parameter is membership (?status=hired&status=interview). A filter name is resolved to a real column before any SQL is assembled; an unknown name answers 400. Array and JSON columns are not filterable and say so. - Sorting —
?sort=fieldascending,?sort=-fielddescending,?sort=(empty) means unordered. Every ordered query renders `ORDER BY NULLS LAST, .id` — nulls last in **both** directions, and the id tiebreaker always. - Pagination —
limitandoffset, both non-negative integers.limitis clamped toMaxLimit = 1000. Each resource's default limit is the literal argument at its frontend call site. - argon2id via
golang.org/x/crypto/argon2, with the OWASP-recommended parameters: 64 MiB memory, 3 iterations, 4 lanes, 16-byte salt, 32-byte key. - Hashes are stored as a complete self-describing PHC record, so verification reads
the parameters out of the stored string rather than assuming today's defaults — a
future cost increase does not invalidate existing hashes.
NeedsRehashexists and is tested. - Minimum length 12 bytes (a byte floor, because that is what the KDF consumes), maximum 1024 bytes (so an unbounded body cannot be hashed at 64 MiB per attempt).
- Nothing in
internal/authlogs, and neither a password, a token nor a hash is ever returned in an error or formatted into a string. - Passwords enter the system only through
cmd/setpassword. There is deliberately no-passwordflag — a password in argv is visible throughpsand lands in shell history — so the routes are an interactive hidden prompt (confirmed twice) or stdin. - A session is a row, not a token payload. The token is 32 random bytes from
crypto/rand, encoded as 43 characters of unpadded base64url. The database stores only its lowercase hex SHA-256, pinned by a CHECK constraint — a raw token fails that pattern, so the catastrophic-and-silent mistake of storing the secret cannot pass. - Plain SHA-256 rather than argon2 is deliberate and the reasoning is recorded: a session token is 256 uniformly random bits, so there is no dictionary to run against it and no work factor worth paying on every request.
- Two expiries.
expires_atslides forward as the session is used;absolute_expires_atis fixed at creation and never moves. Without the second, the first could be slid indefinitely. - Sliding only happens once a session is past a threshold of its idle window, so a
page firing ten requests does not fire ten
UPDATEs. The idle window is recovered from the row rather than from the policy, so a session keeps the lifetime it was issued under. - A background sweeper deletes expired rows every 15 minutes, running once
immediately at startup. Sweeping is housekeeping, not correctness:
Authenticatealready refuses an expired session and deletes the row as it finds it. ON DELETE CASCADEfromusers, so a deleted user cannot leave a live session behind.- Parse and validate the shape of the request. A missing field is a malformed request (422), not a failed login.
- Check both rate limiters, before any expensive work — an attacker must not be able to make the server hash on their behalf.
- Verify the credentials.
auth.Credentials.Verifytakes the same measurable time whether the email exists or not: an unknown email, a user with no password set, and a wrong password all pay a full argon2id derivation against a lazily-built decoy hash. Account status is checked after the password, so a suspended account is indistinguishable from a wrong password in both answer and timing. - Issue the session and set the cookie. A correct password clears the email's failure counter; the address counter is left alone.
last_login_atis stamped best-effort, after the session exists — a failure there is a lost diagnostic, not a reason to refuse a sign-in that already succeeded.MaxMarkdownLength = 65536characters (PostgreSQLlength()counts characters), matching themarkdown_sizeCHECK.MaxVersion = 2147483647, the range ofagent_definitions.versionas a PostgreSQLinteger.- Everything is optional. A definition declaring only an id and a name normalizes to a working agent with documented defaults.
- Nothing unknown survives — statuses, reasoning modes, pages, icons, knowledge kinds and permission roles are checked against closed tables, and an unrecognised value is a named error rather than a dropped key.
- What validates is kept. One bad entry costs its author that entry and a message,
never the rest of the file (
Agent.Errors). - An agent with no skills is deliberately not refused, because several product pages have no assistant skills and answer from their own responder.
- The rejection messages reuse the frontend's own wording wherever the rule is
shared, including its quirks —
ValidateAgentperforms a literalstrings.Contains(raw, "name:")substring test because the JavaScript does. pages: candidates(a bare string, not a sequence) is accepted for an agent and refused for a skill, becausenormalizeAgentcoerces andparseSkillrequires a real sequence.- A definition with no
id:, noname:and a validpages:list gets the idcustomon the frontend and is accepted; the Go parser derives the same id from the same default parameter (AuthoredPath = "custom") so that it does not refuse a definition the editor accepts. - Personal scope —
visibility='personal',owner_user_idset to the session user by the server. A caller never supplies it. - Organization scope —
visibility='organization',owner_user_idNULL, writable by admin and employer only. - Tenant isolation —
org_id = <session org>is on every read, update and delete, including for personal definitions, whoseorg_idisNOT NULLfor exactly this reason. - Duplicate handling — the two partial unique indexes make an id unique per owner
or per tenant, never globally. A collision surfaces as
conflict/ 409 through the repository's error translation. - Server-owned fields —
id,org_id,owner_user_id,created_by,created_date,updated_date, and every projection (definition_id,status,version,name,description,pages). The only client-supplied values aremarkdown,visibility(at creation), and a barestatusonPATCH. - Projection synchronization — a
PATCHthat changesmarkdownrecomputes every projected column in the same statement, so a column can never describe a different document than the one stored. - Verbatim Markdown — the bytes the caller sent are the bytes stored.
- Rate limiting is per-process and in-memory. Two API instances behind a load balancer each allow the full budget, so the effective limit is the limit times the instance count, and a restart clears every counter. Multi-instance deployment needs shared state.
- The client address is
RemoteAddr. Behind a reverse proxy every request appears to come from the proxy, so the per-address budget becomes global. ReadingX-Forwarded-Forinstead would be worse until a trusted-proxy list exists, because a client can send that header itself and mint a fresh budget per request. - The limiter map is bounded by pruning, not by a hard cap, so a flood from many distinct addresses grows it until the next prune.
- There is no CSRF token. The defence is
SameSite=Lax, which does not send the cookie on cross-site POST/PATCH/DELETE. That is adequate for the current same-origin and localhost-development posture and would need re-examination alongside any change to the cookie'sSameSitevalue. - CORS never sets
Access-Control-Allow-Credentials. See §17. - The
permissions:block in a definition is parsed and not enforced. Nodefinition_permissionstable exists. - No rate limiting exists on any endpoint other than login.
- No audit trail of authorization refusals beyond the application log.
- No secret management, key rotation, or encryption at rest is configured in this
repository; those are deployment concerns and
infrastructure/is empty. - API base path —
/api/v1. The server bindsHTTP_HOST:HTTP_PORT, defaulting to127.0.0.1:8080. - CORS — an exact-match allowlist from
HTTP_CORS_ORIGINS. In development with the variable unset, the defaults arehttp://localhost:5173,http://127.0.0.1:5173and thevite previewport. Unset outside development the allowlist is empty and the middleware is not installed, which is same-origin only. - Transport compatibility — field names are snake_case and identical to the
frontend's; ids are opaque strings to the client; the response envelope is one
.dataunwrap;meta.totalexists because the frontend's bare array shape had nowhere to put it. - Preserved client interface — the contract's acceptance criterion is that
swapping the transport inside
base44Client.js, and changing no other frontend file, leaves the application behaving identically. The contract states that if implementing it required editingkrowHooks.js, a page or a component, the contract was wrong and got fixed first. - Connected entities — the 13 resources with registered routes, plus
/meand/me/preferences. - The
{ persisted }return shape —auth.updatePreferences()returns{ user, persisted, error }rather than a bare user, because a swallowedQuotaExceededErrorused to lose account-authored skills silently. Over HTTP a failed write is already a non-2xx, so the shim synthesises the success shape. httpClient.js— Not verified from current repository. The string does not appear anywhere in this checkout.- Any
VITE_API_*environment variable — Not verified from current repository. - The current state of the frontend's own tests — Not verified from current repository. The frontend repository is not part of this checkout.
- An unknown body field. Contract §13.5 made unknown fields a 422 rather than a
silent drop, deliberately. Any field the frontend sends that has no column
produces
validation_failedwith the field named indetails. - An invalid enum value.
job_postingshas three enum columns —english_required,statusandpriority— and a value outside the declared set is a 422 naming the permitted values. - Google Pub/Sub — no reference in the repository. Not verified from current repository.
- GCS-compatible object storage — no reference.
infrastructure/README.mdnames MinIO as a later compose candidate. - Docker —
infrastructure/contains only a README, deliberately empty. It listsdocker-compose.dev.yml,Dockerfile.api,Dockerfile.owliverandotel-collector.yamlas later work, with the stated reason that adding any of them before its phase would be speculative. - Redis — named in
ratelimit.goas the seam for shared limiter state, and ininfrastructure/README.mdas a later compose candidate. Nothing is wired. - Observability —
otel-collector.yamlis listed as later work. Today there is structured JSON logging vialog/slogand nothing else: no metrics, no tracing, no exporter. - Production hardening — no TLS termination, secret management, key rotation, backup policy or deployment manifest exists in this repository.
- Multi-record transactional endpoints (
POST /job-applications/{id}/hire,POST /job-postings/{id}/assignments), which the contract defers because they require touching frontend hooks. - Server-side aggregation, which contract §12.3 identifies as the first thing that
breaks as data grows —
shift_recordsat 500 rows. - Consuming
meta.truncated, which exists so §12.2 is fixable without a contract change. - Defining the semantics of the
permissions:block before storing or enforcing it. - Shared state for the login limiter before deploying more than one instance.
- Do not casually modify migrations. Once a migration has run anywhere other than your own machine it is immutable. Change it and every database that already applied it diverges from every one that has not. Write the next one instead — that is exactly what 000002 and 000003 are.
- Do not bypass tenant scoping.
org_idbelongs in theWHEREclause of every read and write. It is not a filter applied afterwards, and it is not optional on personal rows. - Do not trust a client-provided
org_id. It comes from the user's row, which comes from the session row, which comes from a cookie value the client cannot forge without already holding it. - Do not trust a client-provided
owner_user_id, or any of the eight server-owned identity columns. They are the columns every ownership rule rests on; a caller who could set them could defeat the rule with the same request it constrains. - Do not bypass parser validation.
ValidateAgent/ValidateSkillbeforeParseAgent/ParseSkillbefore persistence, on create and on any update that touchesmarkdown. - Do not mutate stored Markdown. The bytes in are the bytes stored. Normalization reads; it never rewrites. Three tests assert this from three directions.
- Do not invent Agent or Skill fields. Every field exists because the frontend parser has it. A field the Go parser knows and the JS parser does not is a definition the editor cannot read.
- Do not add skill versioning without an explicit architecture decision. The frontend has no notion of it and cannot set one.
- Do not bypass RBAC. The role gate runs in the handler before any query, and the row predicate runs in SQL. Both, every time.
- Do not expose cross-tenant resource existence. 403 means "your role"; 404 means "not yours or not there". Never a message that distinguishes the two.
- Do not introduce NATS.
- Do not introduce RAG or
pgvectorbefore the architecture calls for it. There is no corpus, and migration 000005 declines to create a table for one. - Do not introduce MCP or tool execution prematurely. The parser's stated property is that no code path leads from a definition to execution of any kind; changing that should be a decision, not a side effect.
- Do not introduce Redis prematurely. The limiter names it as the seam; wire it when there is more than one instance, not before.
- Do not modify the frontend during backend-only work unless it is explicitly required. The whole transport migration is affordable because exactly one frontend file changes.
- Do not fake AI execution.
UnavailableExecutorrefuses honestly. A stub that returns plausible text would be worse than one that returns an error. - Do not silently change API contracts.
docs/api-contract.mdis the client's contract. Divergences from it have been reported and written down every time — §13.4 and §13.5 exist for that reason. - Do not let the descriptors and the policy table drift.
resources_gen.gois generated;policy.gois hand-written. That separation is why regenerating one cannot silently drop a rule in the other, andTestEveryResourceHasAPolicyis what keeps it honest. - Do not put reconnaissance in a public response.
/healthanswers the verdict; the reasoning goes to the log. - Do not log a password, a session token, or a password hash. Nothing in
internal/authlogs at all, by design. - We started with a browser-only demo. Data lived in arrays in a tab; there was no identity, no tenancy, and no rule the client did not enforce on itself.
- Go and PostgreSQL were introduced to move persistence, identity and enforcement to a server without rewriting the frontend. The acceptance criterion was written down first: swapping the transport inside one frontend file must change nothing above it. That held.
- The architecture is a plain layered service — handler, service, repository, pgx, PostgreSQL — with no ORM and no web framework. Three direct Go dependencies.
- The schema lives in
migrations/and nowhere else. Five migration pairs; nothing in the Go code issues DDL. Resource descriptors are generated from the live schema so they cannot drift. - The API contract was derived from frontend call sites, not designed. An
operation with no call site gets no endpoint — which is why
badgeshas none andDELETE /job-postings/{id}answers 405. - Authentication is server-side sessions: argon2id passwords, opaque 256-bit tokens, only SHA-256 stored, HttpOnly cookies, sliding expiry with an absolute ceiling, and login rate limiting per email and per address.
- Authorization is deny-by-default. Three roles from
users.role. The role gate answers 403 before any query; tenancy and ownership are SQL predicates, so a row outside them answers 404 and cannot be distinguished from one that does not exist. - Agent and Skill definitions moved out of a jsonb preferences blob into real tables with an owner, a tenant, a size bound and a query surface. The Markdown is the authoritative artefact; every column is derived from it and recomputed on write.
- The Go parser provably matches the JavaScript one — replayed against the real frontend parser's captured output over 37 shipped definitions and 132 adversarial cases, with mutation checks so the tests cannot pass against a broken parser.
- The runtime boundary exists; execution does not. Loading, tenant scoping, status eligibility and dependency resolution with personal-over-organization shadowing are written and tested. The only executor refuses. No HTTP route reaches the runtime at all.
- Verification is real: 175 test functions, 920 test entries, against a disposable
migrated-and-seeded PostgreSQL database per test process.
gofmt,go vetandgo buildare clean. - 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 credentials; ten definition endpoints that sit outside the policy table and outside the written contract; and a repository with a single commit and so no usable history.
- The direction is real execution behind the existing executor interfaces (Owliver / an LLM provider abstraction), then retrieval, then tooling, then deployment infrastructure — none of which exists here today. NATS is not part of the target architecture, and the documentation no longer implies otherwise.
- 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 Markdown.
Error codes and HTTP status
| Code | HTTP | When |
|---|---|---|
invalid_query |
400 | a query parameter is malformed or names an unknown/unfilterable/unsortable field |
unauthorized |
401 | no valid session |
forbidden |
403 | the caller's role does not permit the operation |
not_found |
404 | no such row within the caller's organization and ownership scope |
method_not_allowed |
405 | the path exists but not under this method |
conflict |
409 | a uniqueness constraint was violated |
validation_failed |
422 | a body field is unknown, blank, null on a NOT NULL column, an invalid enum value, or a required field is absent |
rate_limited |
429 | too many failed sign-in attempts; accompanied by Retry-After |
internal |
500 | anything else; detail goes to the log, never to the client |
A resource with no item route at all — assignments, which is only listed and
created — answers 404 rather than 405, because 405 requires the path pattern to
exist under some other method.
Endpoint groups
Routes registered by the server: 51.
routeResources 34 + routeMe 4 + routeAuth 2 + routeDefinitions 10 +
GET /health 1.
docs/api-contract.md §2 enumerates 38 endpoints; those are exactly the 34 resource
routes plus the 4 /me routes. The 2 auth routes are specified in §9 and the 10
definition routes are not in the contract document at all (see §17).
Health
| Method | Path | Notes |
|---|---|---|
GET |
/health |
Public. Body is one field: {"status": …}. "ok" with 200; "degraded" with 200 when the database is up but unmigrated or left dirty; "unavailable" with 503 when it is unreachable, so a load balancer can act on the status code alone. Nothing about the server, database or schema appears in the body. |
Authentication
| Method | Path | Notes |
|---|---|---|
POST |
/api/v1/auth/login |
Public. Body {"email", "password", "remember_me"}. 200 returns the user in the same shape as GET /me and sets the session cookie. 422 when a field is absent. 429 when rate limited. Every credential failure is the same 401. |
POST |
/api/v1/auth/logout |
Public and idempotent. Revokes the session behind the cookie if there is one, expires the cookie, and answers 200 with {"data":{"status":"signed_out"}} — including when the cookie is absent, stale, or was never valid. |
Current user
| Method | Path | Notes |
|---|---|---|
GET |
/api/v1/me |
The user behind the session cookie, with preferences embedded |
PATCH |
/api/v1/me |
Shallow merge. Only full_name and account_type are writable |
GET |
/api/v1/me/preferences |
camelCase keys |
PATCH |
/api/v1/me/preferences |
Shallow merge, returns the merged object |
Core application entities
Registered only where the resource declares the operation, so an unsupported one is answered by the mux rather than by a handler that has to know to refuse.
| Resource | Registered operations |
|---|---|
job-postings |
list, get, create, update |
job-applications |
list, create, update, delete |
ai-interviews |
list, create |
staff |
list, create, update |
worker-profiles |
list, create, update |
courses |
list, get, create, update |
learning-paths |
list |
role-categories |
list, create |
certifications |
list, create, delete |
user-activity |
list, create |
evidence |
list, create, update |
assignments |
list, create |
shift-records |
list |
badges |
none — Ops: 0 |
Agent Definitions
| Method | Path |
|---|---|
GET |
/api/v1/agent-definitions |
POST |
/api/v1/agent-definitions |
GET |
/api/v1/agent-definitions/{id} |
PATCH |
/api/v1/agent-definitions/{id} |
DELETE |
/api/v1/agent-definitions/{id} |
Skill Definitions
| Method | Path |
|---|---|
GET |
/api/v1/skill-definitions |
POST |
/api/v1/skill-definitions |
GET |
/api/v1/skill-definitions/{id} |
PATCH |
/api/v1/skill-definitions/{id} |
DELETE |
/api/v1/skill-definitions/{id} |
Accepted query parameters on both collections: visibility, status,
definition_id, sort, limit, offset. Any other parameter answers 400. Default
limit 100; default sort -created_date. Sortable fields: created_date,
updated_date, name, definition_id, status, version.
Runtime
Runtime execution is currently an internal backend boundary; no public runtime
execution endpoint is implemented. No route in internal/httpserver references the
runtime package.
8. Authentication, RBAC & Multi-Tenancy
Users
users carries email (citext, globally unique since migration 000004),
password_hash (nullable, and NULL until a password is set), role, account_type,
status, org_id and last_login_at. User.IsActive() is status == "active";
User.CanAuthenticate() additionally requires a non-empty password hash.
Password handling
Sessions
| Session kind | Idle lifetime | Absolute cap |
|---|---|---|
| normal | 12 hours | 24 hours |
remember_me |
30 days | 90 days |
Cookie
krow_session, Path=/, HttpOnly, SameSite=Lax, Max-Age matching the
session's lifetime, and Secure whenever APP_ENV != development. The __Host-
prefix was considered and declined because it requires Secure, which cannot be
set over plain HTTP on localhost; the hardening is done by attributes instead, where
it can be conditional. SameSite=Lax rather than Strict (which would drop the
cookie on any cross-site navigation) or None (which would require Secure and send
the cookie on cross-site POSTs).
The token is never in a response body. It goes out in a Set-Cookie header and
nowhere else.
Login flow
Every credential failure produces one identical 401. The reason (no_such_user,
no_password, bad_password, not_active) goes to the log at warn, with the
email — which was already in the request — and never the password.
Rate limiting is a fixed-window counter of failed attempts held in this process's memory: 5 per email and 20 per client address per 15-minute window. Two limiters rather than one, because the budgets are deliberately different sizes — an email is one account, while an address may be a whole office behind NAT. Its limitations are documented in the source itself and repeated in §17.
Authentication middleware
publicPaths = { /health, /api/v1/auth/login, /api/v1/auth/logout }
An allowlist, not a list of protected prefixes, so the failure mode of forgetting to update it is a route that refuses everyone rather than one that serves everyone. It sits below the router, so even the mux's own 404 is behind it.
For every other request:
cookie -> Manager.Authenticate(token)
hash the token, look up the row, refuse if expired (and delete it),
slide the expiry if due
-> users.FindByID(session.UserID)
re-read on EVERY request, not cached in the session row, so suspending
an account takes effect on its next request
-> refuse and revoke if the user is not active
-> authctx.Identity{UserID, OrgID, Email, FullName, Role, AccountType,
Status, SessionID, ExpiresAt} on the context
-> orgctx.With(ctx, user.OrgID)
Nothing in the request influences any of it. The middleware reads no body, no
query string and no header other than Cookie. A user_id or org_id in a body or
query string is ignored.
Roles and the authority
users.role, and only users.role. Three values, fixed by the users_role_check
constraint. An unrecognised role authorizes nothing.
| Role | Who |
|---|---|
admin |
runs the platform for the organization |
employer |
runs the organization's hiring and workforce |
talent |
a worker, acting for themselves |
account_type is not an authorization field. It is a display attribute the user
may change on themselves through PATCH /me, and nothing in the API reads it to make
a decision.
Access resolution
User (session -> users row)
|
+--> Organization org_id, from the user's row, never from the request
| |
| +--> org predicate on every read and write
| (courses / learning_paths also match org_id IS NULL —
| the shared platform library)
|
+--> Role (admin | employer | talent)
|
+--> handler gate: may this role perform this operation?
| no -> 403, before any query runs
|
+--> row predicate: which rows may this role see?
admin, employer -> the whole organization
talent -> an extra WHERE clause
-> outside it: 404
Permission matrix
R = read (list/get), C = create, U = update, D = delete. own means restricted by
a SQL predicate.
| Resource | Admin | Employer | Talent |
|---|---|---|---|
/me, /me/preferences |
R U | R U | R U |
job-postings |
R C U | R C U | R (active only) |
job-applications |
R C U D | R C U D | R C (own) |
ai-interviews |
R C | R C | R C (own) |
staff |
R C U | R C U | — |
worker-profiles |
R C U | R C U | R C U (own) |
assignments |
R C | R C | R (own) |
shift-records |
R | R | R (own) |
courses |
R C U | R | R |
learning-paths |
R | R | R |
role-categories |
R C | R C | R |
certifications |
R C D | R C | R |
user-activity |
R C | R C | R (own) C |
evidence |
R C U | R C U | R C (own) |
badges |
— | — | — |
Two admin-only operations, each with a reason beyond seniority: POST/PATCH /courses, because a course with a NULL org_id is the shared library and a write
there can reach beyond the writer's own tenant; and DELETE /certifications/{id},
because deleting one changes what every existing posting that required it means.
Ownership predicates (talent callers)
| Resource | Predicate |
|---|---|
worker-profiles |
user_id = <session user id> |
job-applications |
email = <session email> |
assignments |
worker_email = <session email> |
shift-records |
worker_email = <session email> |
evidence |
worker_email = <session email> |
user-activity |
user_email = <session email> |
ai-interviews |
application_id IN (SELECT id FROM job_applications WHERE org_id = … AND email = <session email>) |
job-postings |
status = 'active' — visibility rather than ownership |
The ai-interviews case also has a write guard: an insert whose ownership is
expressed by reference has the reference checked against the caller's own
applications, and a reference that is not theirs answers the same 404 a nonexistent
application would.
Server-owned identity columns
Six columns are filled from the session and ignored if present in a request body, because they are the columns every ownership rule rests on:
| Column | Filled with | When |
|---|---|---|
job_postings.created_by |
session user id | always |
user_activity.user_id |
session user id | always |
user_activity.user_email |
session email | always |
user_activity.user_name |
session full name | always |
user_activity.account_type |
session account type | always |
worker_profiles.user_id |
session user id | talent callers only |
Two more are overridden for talent callers and left writable for operators:
job_applications.email and evidence.worker_email. The distinction is between who
acted and who the row is about — when an admin creates a candidate's profile or
files an application on their behalf, the subject is the candidate, not the operator.
PATCH /me has its own allowlist: only full_name and account_type are writable.
role used to be in that map, which would have been a one-line privilege-escalation
path the instant sessions existed. Attempts to write a server-owned user field are
ignored and logged, so a client asking to change its own role is visible even
though the answer is no.
Cross-user and cross-tenant protection
Both are predicates, not filters, so a row outside them is never fetched. Tests
covering this: TestCrossOrganizationIsolation, TestTalentSeesOnlyTheirOwnRecords,
TestTenancyFollowsTheSession, TestIdentityCannotBeSuppliedByTheRequest,
TestForbiddenVersusNotFound, TestOrganizationScoping,
TestTalentCannotInterviewForAnotherApplication.
Deny by default
internal/domain/policy.go maps a URL path to a *Policy. A resource with no
entry keeps a nil policy and permits nothing, to anyone — a table added to the
schema tomorrow is unreachable until someone writes down who may reach it.
TestEveryResourceHasAPolicy makes the omission loud. badges has an empty policy
written out explicitly, so the resource is deliberately closed rather than merely
forgotten.
What is not implemented
No permissions table, no policy engine, no per-record ACL, no role hierarchy, no delegation, no refresh tokens, no multi-factor, no SSO, no OAuth, no password reset flow, no self-service registration, and no email delivery of any kind.
Firebase Auth is an architectural direction, not the current implementation. The string "Firebase" does not appear anywhere in this repository — Not verified from current repository as a recorded plan; it is recorded here because it was named as a direction for this document.
9. Agent & Skill System
The three representations
| Representation | Where | Purpose |
|---|---|---|
| Source-controlled (shipped) | src/agents/**/*.md, src/skills/**/*.md in the frontend repository |
product source, versioned in Git, bundled at build time. No rows in this database. |
| PostgreSQL authored | agent_definitions, skill_definitions |
authored in the app; the Markdown is the authoritative artefact, the columns are projections |
| Runtime | runtime.Agent, runtime.Skill |
the parsed definition plus its database identity and ownership, prepared for execution |
The three tiers share one id namespace, and shadowing across them is the point: a
personal definition may carry the same definition_id as an organization one, which
may carry the same id as a shipped one.
Agent — fields parsed by internal/definition
definition.Agent:
| Field | Type | Notes |
|---|---|---|
ID |
string | from id:; falls back through name and then the origin path |
Name |
string | falls back to Untitled agent, which the validator then refuses |
Description |
string | |
Status |
string | draft / published / archived |
Version |
int | |
Pages |
[]string | canonical surface ids — mapped through the frontend's canonicalPage |
Icon |
string | checked against a closed vocabulary (10 values in the captured oracle) |
Reasoning |
string | fast / balanced / deep |
Trigger |
string | |
WebSearch |
bool | |
Skills |
[]string | ids of skills the agent depends on |
Subagents |
[]string | |
Starters |
[]Starter | {Label, Prompt} — conversation starters |
Knowledge |
[]Knowledge | {ID, Label, Kind, Body, URL}; kinds note / link / skill-reference |
Permissions |
Permissions | {Owner, Access, People[]}; access all / specific; roles manager / editor / viewer. Parsed and not enforced |
Instructions |
string | the body's ## Instructions section — prose belongs under a heading |
Errors |
[]string | what this definition lost on the way in, carried on the record rather than thrown |
Body |
string | the Markdown after the frontmatter |
runtime.Agent additionally carries DatabaseID, Visibility, OwnerUserID,
ResolvedSkills and RawMarkdown.
Skill — fields parsed by internal/definition
definition.Skill:
| Field | Type | Notes |
|---|---|---|
ID |
string | |
Name |
string | falls back to Untitled skill |
Description |
string | |
Status |
string | active / inactive |
Pages |
[]string | as the author wrote them, not canonicalised |
Kind |
string | |
Category |
string | |
Actions |
[]string | |
Triggers |
[]string | |
DeclaredTriggers |
bool | whether the author declared any |
Prompt |
*string | nullable |
SkillID |
*string | nullable |
Levels |
[]Level | {Level, Label, Summary}, read from the body's own headings |
Body |
string | Markdown after the frontmatter, trimmed |
Deferred |
[]string | frontmatter blocks this package does not check — see §10 |
runtime.Skill additionally carries DatabaseID, Visibility, OwnerUserID and
RawMarkdown.
One asymmetry that must not be "fixed"
Agent.Pages holds canonical surface ids; Skill.Pages holds the strings the
author wrote. That is not an oversight: the frontend's normalizeAgent maps every
page through canonicalPage and its parseSkill does not. Making the two agree in
Go would make each one disagree with its own editor. The comment in the source says
so explicitly.
Vocabularies (captured from the frontend)
18 pages with aliases; agent statuses draft/published/archived; reasoning
fast/balanced/deep; 10 icons; knowledge kinds note/link/skill-reference;
access all/specific; permission roles manager/editor/viewer.
TestVocabularyMatchesFrontend asserts the Go tables against the captured ones.
Backend-only bounds
Two rules the frontend editor does not have, both flagged BackendOnly so they are
distinguishable from shared rules:
Both turn a constraint violation into a message an author can act on.
10. Parser & Definition Compatibility
Why the two parsers must agree
Frontend JS parser (src/lib/skills, src/lib/agents)
|
compatibility contract (internal/definition + testdata/oracle.json)
|
Backend Go parser (internal/definition)
A definition is authored in the browser and stored by the server, so both parsers see it. Disagreement is silent in both directions: a definition the editor accepts and the API rejects fails at save time after looking valid; a definition the API accepts and the editor rejects is stored and then cannot be rendered.
The supported YAML subset
internal/definition/yaml.go is a line-for-line port of the frontend's yaml.js.
Supported, and nothing else: block maps and block sequences nested to any depth;
scalars (strings, integers, floats, booleans, null); quoted strings for values
containing : or #; - key: value (a mapping whose first key sits on the dash);
# comments and blank lines.
Not supported: anchors, aliases, merge keys, multi-document files, flow mappings, flow sequences, block scalars, tags. These are not silently half-read — an unparseable line is an error carrying its 1-based line number within the frontmatter, worded exactly as the frontend words it, because an author who sees one message in the editor and another from the API is being told about two different problems.
No YAML dependency, deliberately. A general library would accept a much larger language than the frontend does, and every construct it accepted and the frontend did not would be a definition the backend stores and the editor cannot read.
Nothing here evaluates anything. There is no reflection, no template, and no code path from a definition to execution of any kind. A definition is configuration, and the parser is the boundary that keeps it configuration.
Document layer
frontmatter.go handles byte-order mark stripping, CRLF normalisation, leading blank
lines, fence recognition (including a trailing tab after the closing fence), and body
extraction. A second --- in the document is body, not a new frontmatter block.
Validation and normalization
Raw Markdown is never rewritten
Normalization reads; it does not rewrite what is stored. The Markdown handed in is
the Markdown that goes to the database, byte for byte.
TestParsingDoesNotMutateSource, TestNormalizationIsNotStorage and
TestMarkdownIsStoredVerbatim assert it from three directions.
Deferred blocks
A skill may carry a ui: block (declarative page sections) or an owliver: block
(assistant capabilities). Validating those means reproducing roughly 1,500 lines of
closed vocabulary describing what the frontend can render — placements, data
sources, section types, periods — none of which the backend stores, projects or acts
on.
The package therefore does not check them. It records their presence on
Skill.Deferred, so the gap is a value a caller can see rather than an assumption.
The one consequence is stated exactly in the source: skill-examples/board-invalid-context.md
is rejected by the frontend on a rule about which placement may supply which data
source, and accepted here. It is the only definition in the corpus where the two
disagree, and the conformance suite asserts that it stays the only one.
Visibility is deliberately absent from the parser: the frontend ignores a
visibility: key in a definition entirely. It is a storage tier chosen by the
request and checked by a database constraint. A definition cannot name its own
tenancy.
The conformance corpus
internal/definition/testdata/oracle.json (9,633 lines) is not hand-written. It
is captured by scripts/oracle.mjs, which loads the real frontend module graph
through Vite — import.meta.glob, the @/ alias and raw Markdown loading behave
exactly as they do in the app — and records what the JavaScript parser did with every
definition. Counted from the fixture in this repository:
| Set | Entries | Breakdown | Accepted by JS |
|---|---|---|---|
corpus (shipped definitions) |
37 | 28 skills, 9 agents | 36 accepted, 1 rejected |
cases (adversarial) |
132 | 87 skills, 45 agents | 86 accepted, 46 rejected |
The adversarial cases come from scripts/cases.mjs, which stores raw bytes as an
author could actually produce them — nothing is normalized on the way in, because the
point is what the two parsers do with the awkward form.
The assertion is therefore not "Go agrees with a description of the frontend" but "Go agrees with the frontend", replayed. A frontend change that alters parsing fails these tests, which is the intent: the contract cannot drift silently in either direction.
Mutation checks
TestMutationsWouldBeCaught exists because tests that pass against a broken parser
are not tests. Each entry is a plausible mistake in the Go package, and each must be
caught by a real definition changing its meaning rather than by an assertion written
to notice it. The mutations covered include: dropping the BOM strip; dropping CR
normalisation (so candidates\r stops being a page); trimming the closing fence too
eagerly or not at all; treating a second --- as frontmatter; treating a # inside
a word as a comment; failing to treat a spaced # as a comment; letting a colon
inside quotes split the value; taking the first rather than the last of a duplicate
key; accepting ragged indentation instead of reporting its line; and canonicalising a
skill's page name.
Parser behaviours worth knowing
11. Agent & Skill CRUD
Create flow
POST /api/v1/agent-definitions { "markdown": "...", "visibility": "personal" }
|
authctx.MustFrom(ctx) identity from the session
|
body["markdown"] present? absent -> 422 "Paste or upload a Markdown definition."
|
ValidateAgent(markdown) frontend rules + backend bounds -> 422 with the message
|
visibility resolved defaults to "personal"; must be personal|organization
|
organization? role gate talent (or an unrecognised role) -> 403
|
ParseAgent(markdown) -> id, status, version, name, description, pages
|
AgentInsertInput org_id = session org
created_by = session user
owner_user_id = session user (personal only)
markdown = the caller's bytes, unchanged
|
INSERT ... RETURNING partial unique index -> 409 on a duplicate id per tier
|
{ "data": { … the stored row … } }
Skills follow the same path through ValidateSkill / ParseSkill, minus version.
List, get, update, delete
| Operation | Behaviour |
|---|---|
GET collection |
Scoped to the caller's organization and ownership: visibility='organization' OR (visibility='personal' AND owner_user_id = <session user>). Optional visibility filter narrows to one tier. Returns the standard page envelope with meta. |
GET /{id} |
Same predicate. A non-UUID id, or a row outside the predicate, answers 404 — the caller cannot distinguish the two. |
PATCH /{id} |
Reads the row first (within scope). If the stored row is organization, a talent caller is refused 403. visibility cannot be changed after creation — an attempt answers 422 immutable. If markdown is supplied it is re-validated, re-parsed, and every projection is recomputed from it. Otherwise a bare status change is accepted against the closed status list. |
DELETE /{id} |
Idempotent, matching the contract's §12.7 semantics: a non-UUID id or an already-absent row answers 200 with {"data":{"id":"…"}}. An organization row still enforces the role gate before deleting. |
Scope, ownership and tenancy
Tests covering this surface
TestAgentCreate, TestSkillCreate, TestDefinitionsList, TestDefinitionsGet,
TestDefinitionsPatch, TestDefinitionsDelete, TestSecurityAndSQLInjection,
TestFullCRUDFlowAndProjections (internal/httpserver/definitions_api_test.go, 848
lines).
12. Runtime Architecture
What the package contains
| Type | Role |
|---|---|
Loader |
loads a stored definition by UUID or definition_id, re-parses and re-validates it, and returns a runtime representation |
runtime.Agent |
parsed agent + DatabaseID, Visibility, OwnerUserID, ResolvedSkills, RawMarkdown |
runtime.Skill |
parsed skill + the same identity and ownership fields |
ExecutionInput |
{Identity, TargetID, Input, Parameters, Context} |
ExecutionResult |
{Success, Output, AgentID, AgentVersion, ResolvedSkills, Error} |
RuntimeError |
structured {Code, Message, Target, Cause} with Unwrap |
AgentExecutor / SkillExecutor |
the execution boundary interfaces |
UnavailableExecutor |
the only implementation; refuses execution |
Engine |
ties loader + executors together; RunAgent, RunSkill |
Typed errors
ErrNotFound, ErrUnauthorized, ErrInvalidDefinition, ErrDraftAgent,
ErrArchivedAgent, ErrInactiveSkill, ErrNotExecutable, ErrDependencyMissing,
ErrDependencyInactive, ErrCircularDependency, ErrExecutorUnavailable.
Loading
LoadAgent / LoadSkill accept either a UUID (matched by regex) or a
definition_id, and dispatch to the repository accordingly. The stored markdown is
then re-validated and re-parsed — the runtime does not trust the projected columns
for anything but identity, visibility and ownership. A row whose markdown is absent
or empty answers ErrInvalidDefinition.
Both lookups go through DefinitionsRepo, so tenant and ownership scoping applies
to the runtime exactly as it does to the CRUD API: org_id = <session org> and
visibility='organization' OR (visibility='personal' AND owner_user_id = <session user>).
Status eligibility
Agent Skill
published -> eligible active -> eligible
draft -> ErrDraftAgent inactive -> ErrInactiveSkill
archived -> ErrArchivedAgent other -> ErrNotExecutable
other -> ErrNotExecutable
LoadExecutableAgent applies the agent rule and then resolves dependencies;
LoadExecutableSkill applies the skill rule.
Dependency resolution
Agent
|
agent.Skills (ids from the `skills:` frontmatter)
|
for each id, in declared order:
|
+-- already resolved? -> skip (deterministic deduplication)
|
+-- LoadSkill(ident, id) tenant + ownership scoped
| |
| +-- by definition_id: ORDER BY
| CASE WHEN visibility='personal' THEN 1 ELSE 2 END
| LIMIT 1 personal shadows organization
| |
| +-- not found -> ErrDependencyMissing
|
+-- status != 'active' -> ErrDependencyInactive
|
v
agent.ResolvedSkills ([]*Skill, in declared order)
An agent with no skills: entries resolves to an empty list rather than an error.
Cross-tenant dependency protection falls out of the repository predicate: a skill
belonging to another organization is simply not found, so the dependency resolves to
ErrDependencyMissing rather than to another tenant's definition.
TestRuntime_DependencyResolution covers the cross-org case explicitly, and
TestRuntime_PersonalSkillShadowing covers the shadowing precedence.
Cycle detection is present in the code (inProgress map, ErrCircularDependency)
but is not reachable in the current design — see §17.
The executor boundary
type AgentExecutor interface {
ExecuteAgent(ctx, agent *Agent, input ExecutionInput) (*ExecutionResult, error)
}
type SkillExecutor interface {
ExecuteSkill(ctx, skill *Skill, input ExecutionInput) (*ExecutionResult, error)
}
NewEngine defaults both to UnavailableExecutor and accepts WithAgentExecutor /
WithSkillExecutor overrides. UnavailableExecutor.ExecuteAgent returns a result
carrying the agent id, version and resolved skill ids, with Success: false and
Error: ErrExecutorUnavailable, and returns that error.
Real LLM execution is not part of the current executor boundary. There is no LLM
client, no prompt assembly, no tool dispatch and no external call anywhere in
go-api/. The boundary exists so that adding one is an implementation of an
interface rather than a change to the loading, scoping and eligibility rules.
Reachability
Nine test functions in internal/runtime/runtime_test.go (890 lines) exercise the
package: loader, status eligibility, version semantics, dependency resolution,
executor boundary, personal skill shadowing, malformed Markdown, and skill execution.
Outside those tests, nothing in the repository calls into the package — see §17.
13. Security Architecture
Controls that are present and verifiable
| Control | How it is implemented |
|---|---|
| SQL parameterization | Every value reaches PostgreSQL as a bind parameter cast to its declared type. No identifier ever comes from user input: a filter or sort name is resolved to a *domain.Column before any SQL is assembled, and an unresolved name is rejected. TestSecurityAndSQLInjection covers the definitions surface. |
| Deny-by-default authorization | A resource with no policy permits nothing to anyone. TestEveryResourceHasAPolicy, TestNilPolicyDeniesEverything, TestUnknownRoleIsDenied. |
| Tenant isolation | org_id = <session org> is a predicate on every read and write, taken from the user's row and never from the request. |
| Ownership checks | Predicates in the same WHERE clause, so count(*) matches what the caller may see and no unauthorized row is ever fetched. |
| Server-owned fields | Eight identity columns are filled from the session and override anything in the body; PATCH /me writes only two fields, and attempts on the rest are ignored and logged. |
| Password handling | argon2id at OWASP parameters, self-describing PHC records, a 12-byte floor and a 1024-byte ceiling, no password ever logged or echoed, and no -password CLI flag. |
| Credential-failure uniformity | One 401 for every failure mode, and a decoy argon2id derivation so an unknown email costs the same time as a known one. Account status is checked after the password. |
| Sessions | 256-bit random opaque tokens; only SHA-256 stored, pinned by a CHECK; sliding plus absolute expiry; per-request user re-read so suspension takes effect immediately; expired sessions deleted on contact and swept every 15 minutes. |
| Cookies | HttpOnly, SameSite=Lax, Path=/, Secure outside development, Max-Age matching the session. The token never appears in a response body. |
| Rate limiting | Failed sign-in attempts counted per email (5) and per client address (20) in a 15-minute window, checked before any hashing. |
| CORS | Exact-match allowlist, echoed one origin at a time, * rejected at config load, Vary: Origin always set, and the middleware not installed at all when the allowlist is empty. |
| Input validation | Unknown body fields answer 422 with the field named; enum values are checked against the column's declared set; NOT NULL columns refuse an explicit null; required fields are enforced on create; request bodies are capped at 4 MiB. |
| Health endpoint exposure | One status word and nothing else. Version, database name, schema, migration version, table count, error text, environment and uptime all moved to the server log. TestHealthLeaksNoInfrastructure, TestHealthUnavailableSaysNothingAboutWhy. |
| Error exposure | An unrecognised error is flattened to {"code":"internal","message":"internal error"}; the detail goes to the log. A 403 names neither the caller's role nor the roles that would have worked. |
| Existence non-disclosure | 403 for role, 404 for row. A caller cannot tell "exists and is not yours" from "does not exist". TestForbiddenVersusNotFound. |
| Panic containment | recoverer turns a panic into a logged 500 rather than a dropped connection. |
| Config guards | System schemas rejected; sslmode=disable rejected in production; CORS * rejected. |
| Operational guards | No make migrate-drop; migrate-down gated on APP_ENV=development; test databases are named krow_backend_autotest_<pid> and never touch the development database. |
| Secret hygiene | .env is gitignored; .env.example carries no real values; the connection string is redacted in logs (DBConfig.Redacted). |
Honest limitations
These are the repository's own words where it states them, and direct observation where it does not:
Nothing here should be read as a claim that the service is production-hardened. It is a claim about which controls exist and which do not.
14. Testing & Verification
Toolchain checks — run against this repository
| Check | Command | Result observed |
|---|---|---|
| Formatting | gofmt -l ./cmd ./internal |
no files listed |
| Static analysis | go vet ./... |
no diagnostics |
| Build | go build ./... |
builds |
| Tests | go test ./... -count=1 |
every package's tests ran without failure |
Test counts — counted from this repository
| Measure | Count |
|---|---|
Top-level Test… functions |
175 |
| Total test entries executed, including subtests | 920 |
| Failures | 0 |
| Skipped | 0 (PostgreSQL was reachable on the machine used) |
Per package:
| Package | Top-level test functions | Lines of test code (largest files) |
|---|---|---|
internal/httpserver |
78 | api_test.go 1,114; auth_test.go 926; definitions_api_test.go 848; rbac_test.go 730 |
internal/auth |
26 | session_test.go 488; schema_test.go 376; password_test.go 254 |
internal/domain |
22 | definitions_schema_test.go 748; policy_test.go 191 |
internal/seeder |
18 | seeder_test.go 300; shifts_convergence_test.go 178 |
internal/definition |
13 | conformance_test.go 928 |
internal/runtime |
9 | runtime_test.go 890 |
internal/service |
9 | service_test.go 217 |
cmd/api, cmd/seed, cmd/setpassword, internal/authctx, internal/config,
internal/db, internal/orgctx, internal/repo and internal/testutil have no test
files of their own; internal/repo is exercised through the service and httpserver
suites.
Database-backed tests
internal/testutil builds a disposable database per test process: dropped,
recreated, migrated and seeded, named krow_backend_autotest_<pid> so that
concurrently-running test packages cannot collide, and named distinctly enough that
it cannot be confused with a real database. The package documentation states that
nothing in it ever connects to, reads or drops the development database. Tests skip
rather than fail when PostgreSQL is unreachable.
What the important tests prove
| Area | Representative tests | What they establish |
|---|---|---|
| Contract semantics | TestNullsSortLastInBothDirections, TestStableSortWithIDTiebreaker, TestEndpointSpecificDefaults, TestLimitAndTruncationMeta, TestOffsetPaging |
The two ordering guarantees hold, and each endpoint carries its own defaults. The README records that the ordering tests were verified to fail when the guarantee is removed. |
| Validation | TestCreateRejectsUnknownFields, TestCreateRejectsMissingRequiredAndBlank, TestCreateRejectsInvalidEnum, TestCreateIgnoresServerOwnedFields |
Unknown fields are a 422 rather than silent data loss; server-owned fields are ignored. |
| Idempotent delete | TestDeleteIsIdempotent |
200 whether or not a row matched, as §12.7 requires. |
| Seed fidelity | TestSeedMatchesFixtureCounts, TestSeedPreservesSourceValues, TestSeedRegressionAnchors |
The database is compared against the fixture field-by-field, not against numbers typed into a test. |
| Seed convergence | TestSeedIsIdempotent, TestReseedOnALaterDayLeavesNoStaleShifts, TestReseedIsConvergentAcrossAWeek, TestPruneIsScopedToTheSeededOrganization |
Re-seeding converges the rolling shift window without touching other organizations or API-created rows. |
| Password and session | TestDefaultPasswordParamsMeetOWASP, TestSessionCannotOutliveItsAbsoluteDeadline, TestDatabaseRefusesARawToken, TestUserDeletionCascadesToSessions |
The cost parameters, the absolute cap, the CHECK that refuses a raw token, and the cascade all hold at the database level. |
| Authentication behaviour | TestLoginFailuresAreIndistinguishable, TestLoginSetsHardenedCookieAndNeverReturnsTheToken, TestCookieIsSecureOutsideDevelopment, TestSessionSlidesButNotForever, TestSuspendedUserIsRejectedMidSession, TestLoginRateLimitIsPerEmailAndPerAddress |
Enumeration resistance, cookie hardening, sliding-with-a-ceiling, mid-session suspension, and both limiter dimensions. |
| Authorization | TestRoleMatrix, TestTalentSeesOnlyTheirOwnRecords, TestCrossOrganizationIsolation, TestForbiddenVersusNotFound, TestServerOwnedIdentityCannotBeSupplied, TestTalentCannotInterviewForAnotherApplication, TestTalentSeesOnlyActivePostings, TestExpiredSessionIsRefusedBeforeRoleCheck |
The full matrix, the row predicates, the 403/404 distinction, and that identity cannot be supplied by the request. |
| Policy invariants | TestEveryResourceHasAPolicy, TestDerivedColumnsAreReadOnlyOrTalentScoped, TestOnlyTalentIsRowScoped, TestTalentScopesNameRealColumns |
Regenerating the descriptors cannot silently drop a rule, and a scope cannot name a column that does not exist. |
| Schema invariants | TestDefinitionTablesShape, TestUniquenessPerTier, TestForeignKeysAndDeleteBehaviour, TestMigrationAddsExactlyTwoTables, TestMigration000005IsReversible, TestEveryMigrationHasADownFile |
The definition schema is asserted against the live database, including the deferred tables that must not exist. |
| Parser conformance | TestFrontmatterTreeParity, TestAcceptanceParity, TestRejectionMessageParity, TestProjectionParity, TestAdversarialCoverage, TestDeferredBlocksAreReported |
Go matches the captured JS output over 37 shipped definitions and 132 adversarial cases, including the exact rejection wording. |
| Parser anti-regression | TestMutationsWouldBeCaught, TestParsingDoesNotMutateSource, TestNormalizationIsNotStorage |
Plausible parser mistakes are caught by real definitions changing meaning, and normalization never rewrites what is stored. |
| Runtime | TestRuntime_StatusEligibility, TestRuntime_DependencyResolution, TestRuntime_PersonalSkillShadowing, TestRuntime_ExecutorBoundary, TestRuntime_MalformedMarkdown |
Eligibility, resolution across tenants and tiers, shadowing precedence, and that the boundary refuses rather than pretends. |
| Health | TestHealthEndpoint, TestHealthLeaksNoInfrastructure, TestHealthUnavailableSaysNothingAboutWhy |
The public body carries a verdict and no reconnaissance. |
| CORS | TestCORSAllowsConfiguredOrigin, TestCORSPreflight, TestCORSRefusesUnknownOrigin, TestCORSIgnoresRequestsWithoutOrigin, TestCORSOffByDefault |
Exact matching, and that an origin-less caller is unaffected. |
15. Frontend Integration
React (krow-demo, a separate repository — not part of this checkout)
|
base44Client.js the transport seam; the one frontend file allowed to change
|
HTTP client Not verified from current repository
|
Go API /api/v1, JSON, session cookie
|
PostgreSQL
What is verifiable from this repository
What is not verifiable here
Known integration mismatches
Recorded in docs/api-contract.md §12 and still accurate against the current code:
| # | Mismatch |
|---|---|
| 12.1 | Four flows write several records in a client-side loop with no transaction and no rollback (useHireCandidate, useAssignWorkers, useScreenAllCandidates, useSubmitChallenge). A failure halfway leaves the database inconsistent. useAssignWorkers issues 3n sequential round-trips for n workers. |
| 12.2 | Collection caps truncate silently. worker-profiles caps at 500 sorted by -krow_score, so profile 501 is invisible. meta.truncated exists to make this fixable; nothing consumes it yet. |
| 12.3 | All aggregation — funnels, KPI tiles, charts, attendance rollups, overtime — computes in the browser over the capped arrays. |
| 12.4 | Search is browser-side substring matching over a concatenated string. No endpoint implements search. |
| 12.5 | Email matching is case-insensitive server-side (citext) where store.js used ===. Deliberate and documented as safe. |
| 12.7 | DELETE on a missing record returns 200, because the frontend deletes inside loops without checking and a 404 would surface an error toast where none appears today. |
| 12.8 | InvokeLLM and UploadFile remain local to the browser. Nine AI workflows run deterministically client-side. No endpoint replaces them. |
| §13.4 | updated_date is now always populated where store.js returned undefined; one visible effect is that TalentDetailModal.jsx:66 shows the seeded date rather than today. |
16. Important Architectural Decisions
| Decision | Why | Current implementation | Future direction |
|---|---|---|---|
| Go for the API | A single static binary, a strong standard-library HTTP server, and explicit error handling for a service whose main job is correctness at a boundary | Current. Go 1.27, net/http only, three direct dependencies |
— |
| PostgreSQL, no ORM | The contract's semantics (NULLS LAST both ways, the , id tiebreaker, shallow PATCH, idempotent DELETE) are easier to guarantee in SQL written once than to coax out of a mapper |
Current. internal/repo builds every statement from a descriptor |
— |
| Descriptors generated from the live schema | Column names, types, enum values and nullability then cannot drift from the migrations | Current. make gen-resources → resources_gen.go |
— |
| Policy hand-written, kept apart from descriptors | Regenerating descriptors must never silently drop an access rule, and a new column must never grant anyone anything by accident | Current. internal/domain/policy.go, enforced by TestEveryResourceHasAPolicy |
— |
| One shared query builder, not fourteen repositories | The awkward semantics get implemented once and apply identically everywhere | Current. internal/repo/repo.go |
— |
| Migrations are the source of truth | If the database and migrations/ disagree, migrations/ is right; an applied migration is immutable |
Current. Five pairs; nothing in go-api/ issues DDL |
Expand-migrate-contract as a discrete CI deploy step before the binary rolls out |
org_id on every tenant-scoped table |
Tenancy has to be a predicate on the row, not a convention in the caller | Current. Server-owned on every resource; NOT NULL even on personal definitions |
— |
| Ownership as a SQL predicate, not a filter | count(*) runs over the same clause, so totals are correct and unauthorized rows are never fetched |
Current. builder.ownership |
— |
| 403 for role, 404 for row | A caller must not be able to distinguish "exists and is not yours" from "does not exist" | Current. Role gate in the handler before any query | — |
| API contract derived from call sites | A table existing is never a reason for an endpoint to exist | Current. docs/api-contract.md; badges has no endpoints |
Multi-record transactional endpoints would require touching frontend hooks, so they were deliberately left out of v1 |
| The transport boundary is one frontend file | The whole migration is affordable only if nothing above base44Client.js changes |
Current. Envelope, snake_case fields, opaque ids, per-endpoint defaults | — |
| Sessions are rows; only SHA-256 is stored | A dump of the sessions table must not be replayable as a login | Current. Opaque 256-bit tokens, HttpOnly cookie, CHECK-pinned hash format | — |
| Two expiries per session | A sliding window alone can be slid forever | Current. 12h/24h and 30d/90d | — |
users.role is the sole authority |
account_type is user-writable through PATCH /me and cannot be an authorization field |
Current. Documented in contract §9A.1 | — |
| Verbatim Markdown as the authoritative artefact | A definition must survive a round trip to a .md file on disk unchanged; every column is a cache of it |
Current. markdown stored byte-for-byte; projections recomputed on every write |
— |
| JS/Go parser parity enforced by replay | Two parsers that disagree fail silently in both directions | Current. testdata/oracle.json captured from the real frontend module graph; 37 corpus + 132 adversarial cases |
Regenerate the oracle after any change to src/lib/skills or src/lib/agents |
| No YAML library | A general parser accepts a larger language than the frontend does; every extra construct is a definition the editor cannot read | Current. Hand-ported subset in yaml.go |
— |
| Personal vs organization definitions, two partial unique indexes | Shadowing by id is the point, so the id must be unique per tier and never globally | Current. ..._personal_key and ..._org_key |
— |
| No skill versioning | The frontend has no notion of a skill version and cannot set one; inventing it would create a column forever equal to 1 | Current. skill_definitions has no version column, asserted by TestSkillStatusAndNoVersion |
Would require an explicit architecture decision |
| Two definition tables, not one | Agents and skills do not share a lifecycle; one table would need a union CHECK permitting nonsense states | Current. agent_definitions, skill_definitions |
— |
permissions: parsed but not enforced |
Its semantics are not defined yet, and a half-enforced permission model is worse than none | Current. Parsed onto Agent.Permissions; no definition_permissions table |
Define the semantics before storing them |
| Runtime execution boundary before any executor | Loading, scoping, eligibility and resolution are worth getting right independently of what eventually executes | Current. AgentExecutor/SkillExecutor interfaces; UnavailableExecutor refuses |
Real executors attach here |
| Health endpoint says the verdict, not the reasoning | An unauthenticated endpoint is a public document, not an operator console | Current. One status field; full detail in the server log | — |
| Firebase Auth | — | Future direction. The repository contains no reference to Firebase; the current implementation is server-side sessions with argon2id | Would replace or front the current credential path |
| Google Pub/Sub | — | Future direction. No reference in the repository | — |
| 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 |
— |
| 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. 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 |
17. Known Issues & Current Limitations
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.
17.1 Documentation is behind the code — resolved
Issue as recorded. README.md described the repository as being at an earlier
stage than the code is. It stated "Authorization — which roles may do what — is Phase
3D and is not implemented", "Authorization is not implemented. users.role is
carried on the identity and consulted nowhere: any signed-in user reaches every
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 landed after the README was last revised.
Current behaviour. internal/domain/policy.go, Server.authorize,
builder.ownership and internal/httpserver/rbac_test.go (730 lines) all exist and
run. 51 routes are registered. 175 test functions run. docs/api-contract.md §9A
is current and documents the authorization contract accurately.
Resolution. README.md was revised against the code: the status paragraph, the
layout tree (which was missing cmd/setpassword, internal/auth, internal/authctx,
internal/definition and internal/runtime), the route count, the test count and the
authorization section. The server.go and authctx.go package comments were
corrected to describe the authorization that exists. internal/orgctx/orgctx.go,
which still framed itself as pre-authentication, was corrected in the same pass.
17.2 The definitions endpoints are not in the API contract
Issue. docs/api-contract.md calls itself "the frozen client contract" and
contains zero occurrences of agent-definitions or skill-definitions, yet ten
routes are registered.
Cause. The contract was revised for authorization (§9A) but not for the definitions surface.
Impact. A frontend engineer working from the contract would not know these endpoints exist, and their request/response shapes, filters and error codes are documented only in Go source and tests.
Current behaviour. The endpoints work and are covered by 8 test functions.
Possible future handling. Add a definitions section to the contract.
17.3 A referenced document does not exist
Issue. internal/definition/definition.go states that backend-only rejection
rules "each one is listed in docs/phase-4d-parser-contract.md", and
internal/definition/skill.go refers to "the contract document". docs/ contains
only api-contract.md and this file.
Impact. The two BackendOnly rules are discoverable only by reading
ValidateAgent and ValidateSkill.
17.4 The definitions surface bypasses the policy table
Issue. The ten definition handlers do not call Server.authorize and the
policies map has no entry for agent-definitions or skill-definitions. Role
checks are instead written inline in internal/service/definitions.go — the same
if !known || role == domain.RoleTalent { return nil, domain.Forbidden() } block
appears eight times.
Cause. Definitions are not domain.Resource values, so the descriptor-and-policy
machinery does not reach them.
Impact. The deny-by-default guarantee — enforced by TestEveryResourceHasAPolicy
— does not extend to the newest ten endpoints. A future definition-related endpoint
added without its inline check would be reachable by any signed-in user, and no test
would notice.
Current behaviour. Tenancy and ownership are enforced, in the repository predicates, for every definition read and write. The role rule that exists — organization-visibility definitions are writable by admin and employer only — is applied on create, update and delete, and is covered by tests.
Possible future handling. Bring definitions under the policy table, or add a test that asserts every definition route has an explicit role check.
17.5 The runtime package is not reachable from the running service
Issue. No code outside internal/runtime and its own test file references the
package. There is no HTTP route, no command, and no service that constructs an
Engine.
Impact. The runtime's loading, scoping, eligibility and resolution logic is exercised only by its tests. Nothing a deployed binary does touches it.
Current behaviour. As stated in §7: runtime execution is an internal backend boundary and no public runtime execution endpoint is implemented.
17.6 Cycle detection in dependency resolution is unreachable
Issue. Loader.ResolveAgentDependencies maintains an inProgress map and returns
ErrCircularDependency, but sets inProgress[skillID] = true and back to false
within the same loop iteration, and never recurses — skills do not resolve skills.
The error is therefore not reachable, and no test covers it.
Cause. The guard is shaped for a recursive resolver; the resolver is one level deep.
Impact. A genuine cycle cannot exist in the current model (an agent names skills; skills name nothing), so nothing is currently mis-handled. The code implies a protection that is not active.
Possible future handling. Either make resolution recursive, at which point the guard becomes live and needs a test, or remove it.
17.7 Unchecked type assertions in the runtime loader
Issue. internal/runtime/loader.go lines 67, 72, 129 and 133 perform
rec["id"].(string) and rec["visibility"].(string) without the comma-ok form. Every
other map access in the codebase, including internal/repo/repo.go:346, uses the
defensive form.
Impact. A nil or non-string value would panic. The panic would be caught by
recoverer and become a logged 500 — but only if the runtime were reachable over
HTTP, which it is not.
Current behaviour. The repository projects both columns as ::text, so the
assertion holds in practice.
17.8 CORS does not permit credentials
Issue. internal/httpserver/cors.go never sets
Access-Control-Allow-Credentials: true. Its own comment anticipates this — "so
adding credentials later does not require rewriting this" — but the header was not
added when cookie authentication landed.
Impact. A browser at http://localhost:5173 calling the API at
http://127.0.0.1:8080 with credentials: 'include' will have the response blocked
by the browser, even though the server answered correctly. This affects exactly the
cross-origin development setup the CORS middleware exists for. A same-origin
deployment is unaffected.
Current behaviour. Five CORS tests exist and pass; none asserts anything about credentials.
Possible future handling. Set the header for allowlisted origins, and add a test.
17.9 Login rate limiting does not survive scale-out
Covered in §13 under limitations: per-process, in-memory, RemoteAddr-based, pruned
rather than capped. The source documents all three limitations itself. Impact:
the effective limit multiplies by instance count, resets on restart, and degrades to
a global budget behind a reverse proxy. Possible future handling: move the
limiter behind shared state before deploying more than one instance.
17.10 The repository has almost no history
Issue as recorded. git log reported fatal: your current branch 'main' does not have any commits yet, and every file was untracked.
Current behaviour. The tree is now committed: a single commit on main
(7d12ebe, "first commit") holds the whole repository.
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 — resolved
internal/httpserver/api.go — the doc comment describing decodeBody sat
immediately above decodeInto, so decodeInto carried two doc comments and
decodeBody carried none. The decodeBody comment has been moved to sit above
decodeBody.
17.12 Badge endpoint mismatch — verified
Issue. The badges table exists and is seeded with 4 records, but there is no
endpoint for it.
Cause. Recorded in contract §2: the frontend's useBadges hook has zero
consumers, and every badge the UI renders comes from
worker_profiles.earned_badges. The table exists; nothing reads it.
Impact. A frontend Badge.list() call would receive a 404. README.md states
that this call "has 404ed since Phase 2C".
Current behaviour. badges declares Ops: 0, so no route is registered, and its
policy is written out as an explicit empty policy so the resource is deliberately
closed rather than merely forgotten.
Possible future handling. Add the endpoint if a consumer appears, or drop the table.
17.13 Job Posting 422 — mechanism verified, no specific defect recorded
Issue as raised. A "Job Posting 422 validation issue".
What the repository shows. No artifact — comment, test, TODO or contract note — records a specific job-posting defect. Not verified from current repository.
What can be stated factually. POST /api/v1/job-postings answers 422 in exactly
the documented cases, and two of them are easy to hit from a client that was written
against the in-browser store:
title is the only column marked Required on this resource, so a missing-required
422 would name title specifically.
Possible future handling. If the symptom is reproducible, the details map in
the 422 body names the offending field, which is enough to decide whether the column
is absent from the schema (as interview_id, training_outline and
score_breakdown once were) or the value is wrong.
17.14 Shift snapshot / date behaviour — verified
Not a defect; a documented design constraint. Shift records are generated rather than
snapshotted because their dates are anchored to now, and the frontend windows every
collection on created_date. A frozen snapshot would read as permanently empty a
fortnight later. The consequence, recorded as U1 in the contract, is that attendance
and overtime will be empty in any real deployment until a rostering source exists.
Re-seeding on a later day prunes stale rows to keep the rolling window convergent.
17.15 resetDemoData — not present
The string resetDemoData does not appear anywhere in this repository. Not verified
from current repository. It is presumably a frontend concern.
17.16 Frontend test drift — not verifiable here
The frontend repository is not part of this checkout. Not verified from current repository.
17.17 NATS appears in the repository despite the stated direction
Issue. The direction stated for this document is that NATS is not part of the
target architecture. infrastructure/README.md currently lists "Redis, NATS, MinIO"
as candidates for a later docker-compose.dev.yml, and README.md mentions NATS
among the things that do not exist yet.
Impact. A reader of infrastructure/README.md would take NATS to be planned.
Resolution. The rule stands, so the table was amended: infrastructure/README.md
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
These are recorded in docs/api-contract.md §12 with reasons and are not bugs:
multi-record writes are not transactional (§12.1); collection caps truncate silently
(§12.2); all aggregation is client-side (§12.3); search is browser-side (§12.4); email
comparison is case-insensitive server-side (§12.5); DELETE on a missing record
returns 200 (§12.7); LLM and file-upload integrations remain in the browser (§12.8).
18. Future Technical Direction
Nothing in this section is implemented. Each item is labelled with what the repository actually says about it, so the two are never confused.
Agent Runtime / Owliver
The intended direction is a Python "Owliver" service alongside the Go API, with a
subagent bridge and orchestration, and real execution behind the existing
AgentExecutor / SkillExecutor interfaces.
What the repository says: README.md states "a Python Owliver service to follow"
and "No Owliver". internal/runtime/executor.go names "real AI / Owliver / LangGraph
executors" as what would replace UnavailableExecutor.
infrastructure/README.md lists Dockerfile.owliver as later work. No Python source,
no bridge and no orchestration code exists here.
AI Runtime
An LLM provider abstraction, with an appropriate provider per workload.
What the repository says: nothing. No provider name — OpenAI, Anthropic, Gemini, Vertex — appears anywhere in this checkout. Not verified from current repository. The attachment point is the two executor interfaces.
RAG
pgvector, embeddings and retrieval over an agent's knowledge corpus.
What the repository says: pgvector is named exactly once, in
infrastructure/README.md, as part of a later dev compose file. It is not
installed in the local database (only citext and plpgsql are). Migration 000005
explicitly declines to create an agent_knowledge table because "no corpus exists".
Contract §11 lists RAG and embeddings as D7–D10, "none touch v1".
Tools
FastMCP and a tool-execution layer.
What the repository says: nothing. Neither "MCP" nor "FastMCP" appears anywhere.
Not verified from current repository. Note that internal/definition/yaml.go
states as a design property that "there is no code path from a definition to
execution of any kind" — introducing tool execution changes that property
deliberately and should be done knowingly.
Infrastructure
NATS
NATS is not part of the target architecture. infrastructure/README.md no longer
lists it as a candidate; see §17.17.
Nearest-term work implied by the code itself
Not a roadmap, but the things the repository points at:
19. What We Have Built So Far
For a team audience, in plain terms.
Where we started. A React demo whose data lived in a browser tab. store.js held
arrays, seed.js filled them at boot, and every hook called through one file:
base44Client.js. Nothing survived a refresh, everyone saw the same data, and no
rule existed that the client did not enforce on itself.
Where we are. A Go service in front of PostgreSQL that the same React app talks to through the same one file.
| What we built | What it is practically worth |
|---|---|
| A Go API over PostgreSQL | Data survives. Two people see the same records. A query that takes 200 ms in the browser over 500 rows takes a millisecond in an index. |
| A written contract derived from call sites | Nobody had to guess what the frontend needed, and no endpoint exists that nothing calls. The frontend migration cost exactly one file. |
| Migrations as the source of truth | The schema has a history, a rollback, and a review surface. No table has ever been created by hand. |
| Generated resource descriptors | Column names, types and enums cannot drift from the database, because they are read out of it. |
| A seeder that runs the frontend's own seed | The demo dataset in PostgreSQL is the demo dataset the frontend ships, not a transcription of it. Re-seeding is safe and converges. |
| Session authentication | There is a real identity behind every request, held in an HttpOnly cookie the page cannot read, backed by a row we can revoke. |
| Role-based authorization with deny-by-default | Who may do what is written down in one table, and a resource nobody has written a rule for is unreachable rather than open. |
| Tenant and ownership isolation in SQL | A row from another organization, or another worker, is never fetched — so it cannot leak through a count, a total, or a bug in a later loop. |
| Agent and Skill authoring in the database | Authored definitions moved out of a jsonb blob in user preferences into real tables with an owner, a tenant, a size bound and a query surface. |
| A Go parser that provably matches the JS one | An author sees the same acceptance, the same rejection and the same wording in the editor and from the API — asserted against the real frontend parser's captured output over 37 shipped definitions and 132 adversarial cases. |
| A runtime boundary | Loading, tenant scoping, status eligibility and dependency resolution are written and tested, so when execution arrives it plugs into an interface rather than reopening those questions. |
| A test suite that runs against a real database | 175 test functions, 920 test entries, a disposable migrated-and-seeded database per test process, and ordering guarantees verified to fail when removed. |
What is deliberately not built yet. Real AI execution. Any public runtime
endpoint. Server-side aggregation. Transactional multi-record flows. RAG. Tooling.
Anything in infrastructure/.
20. Current Backend Snapshot
Current Database: PostgreSQL 18.6 locally; one schema (public); five migration
pairs, applied version 5, not dirty; 20 application tables plus schema_migrations;
16 enum types; extensions citext and plpgsql; UUID primary keys with a nullable
unique legacy_id; timestamptz throughout.
Current API: Go 1.27, net/http only, base path /api/v1, 51 registered routes
(34 resource + 4 /me + 2 auth + 10 definitions + /health). JSON envelope with
data and, on collections, meta. Two filter operators, NULLS LAST in both
directions with an id tiebreaker, limit/offset with per-endpoint defaults and a
1000 cap, and nine error codes.
Current Authentication: email + password with argon2id at OWASP parameters;
opaque 256-bit session tokens stored only as SHA-256; HttpOnly, SameSite=Lax
cookies with Secure outside development; sliding expiry with an absolute ceiling
(12h/24h, or 30d/90d with remember-me); a 15-minute expired-session sweeper; failed
attempts limited per email and per address; uniform 401s with decoy hashing to resist
enumeration. Passwords enter only through cmd/setpassword.
Current RBAC: three roles from users.role — admin, employer, talent.
Deny-by-default policy table keyed by URL path. Role gate in the handler answering
403 before any query. Eight server-owned identity columns. PATCH /me writes only
full_name and account_type.
Current Tenant Model: one organization per user, taken from the user's row and
never from the request. org_id predicate on every read and write. courses and
learning_paths also match org_id IS NULL, the shared platform library. Talent
callers carry a second ownership predicate in the same WHERE clause; a row outside
either predicate answers 404.
Current Agent System: Markdown with YAML frontmatter, stored verbatim in
agent_definitions; six projected columns; draft/published/archived plus a
monotonic integer version; personal and organization tiers with per-tier uniqueness;
full CRUD over five routes.
Current Skill System: the same shape in skill_definitions, minus version;
active/inactive; full CRUD over five routes. ui: and owliver: frontmatter
blocks are recorded as deferred rather than validated.
Current Runtime: an internal boundary. Loads by UUID or definition_id within
the caller's tenant and ownership scope, re-parses and re-validates the stored
Markdown, applies status eligibility, resolves an agent's skill dependencies with
personal-over-organization shadowing and deduplication, and dispatches to an executor
interface. The only executor implementation refuses execution. No HTTP route reaches
it.
Current Frontend Integration: the frontend repository is separate and unmodified.
The seam is base44Client.js; the contract's acceptance criterion is that swapping
the transport inside it changes nothing above it. CORS allowlist defaults to the Vite
dev server on both hostnames in development, and to empty elsewhere.
Current Testing: 175 test functions, 920 test entries executed, no failures and
none skipped on a machine with PostgreSQL reachable. gofmt reports no files,
go vet reports no diagnostics, go build ./... builds. Database-backed tests use a
disposable krow_backend_autotest_<pid> database per test process.
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; definitions endpoints bypass the policy table; the runtime package is unreachable from the running service; the definitions endpoints are absent from the API contract; attendance data is empty without a rostering source; the repository has a single commit and so no usable history.
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 of it. The open seams the code itself points at are real executors behind the executor interfaces, and a public surface for the runtime if one is wanted.
21. Critical Engineering Rules
These are the invariants the current code depends on. Breaking any of them silently is how this codebase would stop being trustworthy.
22. Final Team Summary
What the team should know
Generated from the repository state on 2026-08-24. Every count, version and 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 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.