agent build

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

View File

@@ -0,0 +1,274 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// Periods a caller may ask for. A closed set, because a period is a window this
// code computes — never a date a caller supplies, and never a string
// interpolated anywhere near SQL.
var periodWindows = map[string]time.Duration{
"today": 24 * time.Hour,
"last-7-days": 7 * 24 * time.Hour,
"last-30-days": 30 * 24 * time.Hour,
"this-month": 0, // computed from the 1st — see windowFor
"previous-month": 0,
}
type activityInput struct {
Period string `json:"period"`
Limit int `json:"limit"`
}
// ActivityBreakdown counts the audit log by kind of event and by account.
//
// The Go port of `activity.breakdown` from the frontend's dataResolver, and the
// difference is the entire point of Phase 2. The JavaScript took records that
// had already been fetched into the browser:
//
// 'activity.breakdown': ({ activity = [] }, section, now) => …
//
// It had no principal, so it could not have authorized anything even if it had
// wanted to — the filtering had already happened, or hadn't, somewhere else.
// This version takes the caller, resolves the policy, and pushes the resulting
// predicate into the query. A talent caller's own scope is a WHERE clause, so
// the counts, the shares and the "accounts active" figure are all computed over
// exactly the rows that caller could have read directly. I1 and I2, in one
// query.
func ActivityBreakdown(db repo.Querier) Tool {
return Tool{
Name: "activity_breakdown",
Description: "Count workspace activity by kind of event and by account, " +
"optionally within a period. Returns totals, the number of distinct event " +
"kinds, how many accounts were active, and a row per event kind with its " +
"count and share of the total. Use this for questions about what has " +
"happened, who did it, and in what proportion.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"period": map[string]any{
"type": "string",
"enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"},
"description": "The window to count within. Omit to count the whole log. " +
"Windows are computed from the current date; do not pass a date.",
},
"limit": map[string]any{
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "How many event kinds to return, most frequent first. Defaults to 20.",
},
},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: activityBreakdownHandler(db),
}
}
func activityBreakdownHandler(db repo.Querier) Handler {
return func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
// Authorization, first line of the body. §8.
role, ok := domain.ParseRole(tc.Principal.Role)
if !ok || !activityPolicy().Allows(domain.OpList, role) {
return Denied()
}
if tc.OrgID() == "" {
// No tenant means no query. I5 — there is no "all organizations"
// read, and a missing org is a bug upstream, not a wildcard.
return Denied()
}
var in activityInput
if len(inputs) > 0 {
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
}
if in.Limit <= 0 {
in.Limit = 20
}
if in.Limit > 100 {
in.Limit = 100
}
from, to, err := windowFor(in.Period, time.Now())
if err != nil {
return Failf(CodeInvalidInput, "%s", err.Error())
}
// The predicate, built from the policy rather than written by hand.
// Every value is a bind parameter; no identifier comes from input.
where := []string{"org_id = $1::uuid"}
args := []any{tc.OrgID()}
if scope := activityPolicy().ScopeFor(role); scope.Kind == domain.ScopeEmail {
// A talent caller sees their own entries. Pushed into the query,
// so the totals and shares below are computed over their rows and
// nobody else's — post-filtering here would leak the organization's
// volume through every percentage.
args = append(args, tc.Principal.Email)
where = append(where, fmt.Sprintf("%s = $%d", scope.Column, len(args)))
}
if !from.IsZero() {
args = append(args, from)
where = append(where, fmt.Sprintf("created_date >= $%d", len(args)))
args = append(args, to)
where = append(where, fmt.Sprintf("created_date < $%d", len(args)))
}
query := `
SELECT event_type, count(*) AS n
FROM user_activity
WHERE ` + strings.Join(where, " AND ") + `
GROUP BY event_type
ORDER BY n DESC, event_type ASC`
rows, err := db.Query(ctx, query, args...)
if err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
defer rows.Close()
type kind struct {
Event string `json:"event"`
Count int64 `json:"count"`
Share int `json:"sharePercent"`
}
var (
kinds []kind
total int64
)
for rows.Next() {
var eventType string
var n int64
if err := rows.Scan(&eventType, &n); err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
total += n
// The stored name is a machine key. Rendered as words so the model
// is not left translating `hire_candidate` and guessing.
kinds = append(kinds, kind{Event: strings.ReplaceAll(eventType, "_", " "), Count: n})
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
for i := range kinds {
if total > 0 {
kinds[i].Share = int(float64(kinds[i].Count)/float64(total)*100 + 0.5)
}
}
// Counted before the limit is applied, so "how many kinds are there"
// stays true even when the list shown is shorter.
distinctKinds := len(kinds)
omitted := 0
if len(kinds) > in.Limit {
omitted = len(kinds) - in.Limit
kinds = kinds[:in.Limit]
}
accounts, err := countActiveAccounts(ctx, db, where, args)
if err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
data := map[string]any{
"totalEvents": total,
"distinctKinds": distinctKinds,
"activeAccounts": accounts,
"kinds": kinds,
"period": periodOrAll(in.Period),
}
// Never a silent drop: a model handed a shortened list with no marker
// reasons about it as if it were the whole.
if omitted > 0 {
data["omittedKinds"] = omitted
}
if total == 0 {
data["note"] = "No activity matches that. This is a real answer, not a failure to look."
}
return OK(data)
}
}
// countActiveAccounts counts distinct actors under the same predicate.
//
// The same WHERE the breakdown used, so the two figures cannot disagree — a
// separate hand-written predicate here is exactly how "12 events across 40
// accounts" gets shipped.
func countActiveAccounts(ctx context.Context, db repo.Querier, where []string, args []any) (int64, error) {
var n int64
err := db.QueryRow(ctx,
`SELECT count(DISTINCT user_email) FROM user_activity WHERE `+strings.Join(where, " AND "),
args...,
).Scan(&n)
return n, err
}
func periodOrAll(p string) string {
if p == "" {
return "all time"
}
return p
}
// windowFor resolves a period name to a half-open [from, to).
//
// Computed from the clock at read time, never stored and never supplied. A zero
// `from` means "no window" — count everything.
func windowFor(period string, now time.Time) (from, to time.Time, err error) {
if period == "" {
return time.Time{}, time.Time{}, nil
}
if _, ok := periodWindows[period]; !ok {
return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period)
}
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
switch period {
case "today":
return startOfDay, startOfDay.AddDate(0, 0, 1), nil
case "last-7-days":
return startOfDay.AddDate(0, 0, -6), startOfDay.AddDate(0, 0, 1), nil
case "last-30-days":
return startOfDay.AddDate(0, 0, -29), startOfDay.AddDate(0, 0, 1), nil
case "this-month":
return firstOfMonth, firstOfMonth.AddDate(0, 1, 0), nil
case "previous-month":
return firstOfMonth.AddDate(0, -1, 0), firstOfMonth, nil
}
return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period)
}
// activityPolicy is the audit log's access rules.
//
// Read from the descriptor table rather than restated here. §13 lists "passing
// the tenant id as a plain function argument through five layers" as an
// anti-pattern for the same reason this matters: an authorization rule with two
// copies has two chances to drift, and the copy in the tool layer would be the
// one nobody re-reads when the policy changes.
//
// A missing descriptor yields a nil *Policy, which denies everything — the
// deny-by-default the table already promises.
func activityPolicy() *domain.Policy {
res, ok := domain.ResourceByPath["user-activity"]
if !ok {
return nil
}
return res.Policy
}

View File

@@ -0,0 +1,210 @@
package tools_test
import (
"context"
"encoding/json"
"testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/testutil"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// freshOrg creates an organization with no history.
//
// The harness seeds a demo tenant that already has activity in it, so these
// tests build their own: an assertion of "exactly 10 events" is only a real
// assertion when the fixture controls every row.
func freshOrg(t *testing.T, h *testutil.Harness, slug string) string {
t.Helper()
var id string
if err := h.Pool.QueryRow(context.Background(),
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`,
slug, slug).Scan(&id); err != nil {
t.Fatalf("create org %s: %v", slug, err)
}
return id
}
// seedActivity writes audit rows for two accounts in one org, and one in
// another, so a leak across either boundary is detectable.
func seedActivity(t *testing.T, h *testutil.Harness, mineOrg, otherOrg string) (admin, talent authctx.Identity) {
t.Helper()
ctx := context.Background()
rows := []struct {
org, eventType, email string
n int
}{
{mineOrg, "login", "boss@example.test", 5},
{mineOrg, "hire_candidate", "boss@example.test", 3},
{mineOrg, "login", "worker@example.test", 2},
// A different tenant entirely. Must never appear in either caller's
// counts, shares or account total.
{otherOrg, "login", "outsider@example.test", 40},
{otherOrg, "delete_position", "outsider@example.test", 40},
}
for _, r := range rows {
for i := 0; i < r.n; i++ {
if _, err := h.Pool.Exec(ctx,
`INSERT INTO user_activity (org_id, event_type, user_email, user_name)
VALUES ($1::uuid, $2, $3, 'Someone')`,
r.org, r.eventType, r.email); err != nil {
t.Fatalf("seed activity: %v", err)
}
}
}
return authctx.Identity{UserID: "u1", OrgID: mineOrg, Role: "admin", Email: "boss@example.test"},
authctx.Identity{UserID: "u2", OrgID: mineOrg, Role: "talent", Email: "worker@example.test"}
}
// seeded builds two fresh tenants and fills them. Returns the two callers.
func seeded(t *testing.T, h *testutil.Harness) (admin, talent authctx.Identity) {
t.Helper()
mine := freshOrg(t, h, "mine-co")
other := freshOrg(t, h, "other-co")
return seedActivity(t, h, mine, other)
}
func runBreakdown(t *testing.T, h *testutil.Harness, ident authctx.Identity, args string) tools.Result {
t.Helper()
reg := tools.NewRegistry()
reg.MustRegister(tools.ActivityBreakdown(h.Pool))
return reg.Dispatch(context.Background(),
tools.Context{Principal: ident, RunID: "run_test"},
"activity_breakdown", json.RawMessage(args))
}
func data(t *testing.T, res tools.Result) map[string]any {
t.Helper()
if res.Error != nil {
t.Fatalf("unexpected tool error: %s — %s", res.Error.Code, res.Error.Message)
}
encoded, _ := json.Marshal(res.Data)
var m map[string]any
if err := json.Unmarshal(encoded, &m); err != nil {
t.Fatalf("result was not an object: %v", err)
}
return m
}
func TestActivityBreakdownScopesToTheTenant(t *testing.T) {
h := testutil.New(t)
admin, _ := seeded(t, h)
got := data(t, runBreakdown(t, h, admin, `{}`))
// 10 in this org. The other tenant's 80 must not be counted, and must not
// show up in the share arithmetic either.
if n := got["totalEvents"].(float64); n != 10 {
t.Errorf("totalEvents = %v, want 10 — the other tenant's rows leaked", n)
}
if n := got["activeAccounts"].(float64); n != 2 {
t.Errorf("activeAccounts = %v, want 2", n)
}
// `delete_position` exists only in the other org.
encoded, _ := json.Marshal(got["kinds"])
if string(encoded) != "" && contains(string(encoded), "delete position") {
t.Errorf("an event kind from another tenant appeared: %s", encoded)
}
}
func TestActivityBreakdownScopesTalentToTheirOwnRows(t *testing.T) {
// I1: the agent may read exactly what the caller could read directly. The
// policy scopes a talent caller to their own email, and this asserts the
// scope is applied as a pre-filter — the totals and shares are computed
// over their rows alone.
h := testutil.New(t)
_, talent := seeded(t, h)
got := data(t, runBreakdown(t, h, talent, `{}`))
if n := got["totalEvents"].(float64); n != 2 {
t.Errorf("totalEvents = %v, want 2 — a talent caller must see only their own entries", n)
}
// The leak that post-filtering would produce: the row text is hidden but
// the organization's volume shows through the account count.
if n := got["activeAccounts"].(float64); n != 1 {
t.Errorf("activeAccounts = %v, want 1 — the tenant's account count leaked through the aggregate", n)
}
encoded, _ := json.Marshal(got["kinds"])
if contains(string(encoded), "hire candidate") {
t.Errorf("a talent caller saw an event kind they did not perform: %s", encoded)
}
}
func TestActivityBreakdownDeniesWithoutATenant(t *testing.T) {
h := testutil.New(t)
res := runBreakdown(t, h, authctx.Identity{UserID: "u", Role: "admin", Email: "x@example.test"}, `{}`)
if res.Error == nil || res.Error.Code != tools.CodeDenied {
t.Fatalf("want a denial, got %+v", res)
}
// §8: the denial must not reveal whether anything exists.
if contains(res.Error.Message, "activity") || contains(res.Error.Message, "org") {
t.Errorf("the denial described what was refused: %q", res.Error.Message)
}
}
func TestActivityBreakdownDeniesAnUnknownRole(t *testing.T) {
// Deny by default: a role the policy table does not list permits nothing.
h := testutil.New(t)
res := runBreakdown(t, h,
authctx.Identity{UserID: "u", OrgID: h.OrgID, Role: "superuser", Email: "x@example.test"}, `{}`)
if res.Error == nil || res.Error.Code != tools.CodeDenied {
t.Fatalf("an unlisted role must be denied, got %+v", res)
}
}
func TestActivityBreakdownRejectsAnUnknownPeriod(t *testing.T) {
h := testutil.New(t)
admin, _ := seeded(t, h)
res := runBreakdown(t, h, admin, `{"period":"since-tuesday"}`)
if res.Error == nil || res.Error.Code != tools.CodeInvalidInput {
t.Fatalf("want invalid input for an unknown period, got %+v", res)
}
}
func TestActivityBreakdownCountsKindsBeforeLimiting(t *testing.T) {
// "How many kinds are there" must stay true even when the list is shorter,
// and the shortening must be declared rather than silent.
h := testutil.New(t)
admin, _ := seeded(t, h)
got := data(t, runBreakdown(t, h, admin, `{"limit":1}`))
if n := got["distinctKinds"].(float64); n != 2 {
t.Errorf("distinctKinds = %v, want 2 — counted before the limit", n)
}
if n, ok := got["omittedKinds"].(float64); !ok || n != 1 {
t.Errorf("omittedKinds = %v, want 1 — a shortened list must say so", got["omittedKinds"])
}
}
func TestActivityBreakdownReportsEmptyAsAnAnswer(t *testing.T) {
h := testutil.New(t)
empty := freshOrg(t, h, "empty-co")
admin := authctx.Identity{UserID: "u", OrgID: empty, Role: "admin", Email: "boss@example.test"}
got := data(t, runBreakdown(t, h, admin, `{}`))
if n := got["totalEvents"].(float64); n != 0 {
t.Fatalf("totalEvents = %v, want 0 on an empty log", n)
}
if _, ok := got["note"]; !ok {
t.Error("an empty result must say it is a real answer, not a failure to look")
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}

View File

@@ -0,0 +1,224 @@
package tools_test
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/repo"
"github.com/krow/krow-backend/go-api/internal/testutil"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// everyTool is the shipped registry, built the same way the service builds it.
//
// Duplicated from runtime.DefaultTools deliberately: importing the runtime here
// would make the tool package depend on its own caller. The list is asserted
// against the registry's own count below, so the two cannot drift silently.
func everyTool(db repo.Querier) []tools.Tool {
return []tools.Tool{
tools.ActivityBreakdown(db), tools.ActivitySignals(db),
tools.WorkforceAttendance(db), tools.WorkforceOvertime(db),
tools.WorkforceCoverage(db), tools.WorkforceTraining(db),
tools.CandidatesQuality(db), tools.HiresRecent(db),
tools.HiresPerformance(db), tools.PositionsRisk(db),
tools.TalentPool(db), tools.WorkspaceSummary(db), tools.OperationsRisk(db),
tools.OpenPositions(db), tools.AvailableWorkers(db), tools.AssignWorker(db),
// Retrieval. Nil retriever here: the smoke test drives it with no
// corpus, and "there are no documents to search" is the honest answer
// for a deployment with no knowledge layer wired.
tools.KnowledgeSearch(nil),
tools.CandidatesAwaiting(db), tools.MoveApplication(db),
}
}
// smokeArgs are the arguments a tool needs before it will do anything.
//
// Most take none. The two that do are the ones that name a moment rather than a
// window, and a required argument is not something to paper over with a default
// — a lookup that silently assumed "now" would smoke-test a statement nobody
// runs in production.
var smokeArgs = map[string][]string{
"available_workers": {`{"starts_at":"2030-01-01T09:00:00Z","ends_at":"2030-01-01T17:00:00Z"}`},
// A role id and an email that do not exist. The statement still executes,
// which is all this test checks; the call is refused on the row not being
// found, which is the correct outcome for arguments this made up.
"assign_worker": {`{"job_posting_id":"00000000-0000-0000-0000-0000000000ff",` +
`"worker_email":"nobody@example.test","starts_at":"2030-01-01T09:00:00Z"}`},
// Driven with no retriever wired, so it refuses. Exercised anyway: a tool
// registered in the service and never called by any test is a tool whose
// schema nobody has looked at.
"knowledge_search": {`{"query":"lateness policy"}`},
// An application id that does not exist. The statement still executes; the
// call is refused on the row not being found, which is correct.
"move_application": {`{"application_id":"00000000-0000-0000-0000-0000000000ff","stage":"interview"}`},
}
// TestEveryToolRunsAgainstTheRealSchema is the smoke test that catches a
// mistyped column or a status literal that is not in its enum.
//
// Both fail silently in SQL: a wrong enum member matches no rows and raises
// nothing, so a bad guess reads as a confident zero. Only executing the
// statement against the real schema finds it, which is why this runs every
// tool rather than sampling.
func TestEveryToolRunsAgainstTheRealSchema(t *testing.T) {
h := testutil.New(t)
admin := authctx.Identity{
UserID: "00000000-0000-0000-0000-000000000001",
OrgID: h.OrgID, Role: "admin", Email: "admin@example.test",
}
reg := tools.NewRegistry()
for _, tool := range everyTool(h.Pool) {
reg.MustRegister(tool)
}
// Every tool, with no arguments and with a period, so both the windowed
// and unwindowed statements are executed.
for _, name := range reg.Names() {
argSets := []string{`{}`, `{"period":"last-30-days"}`, `{"period":"this-month","limit":3}`}
if custom, ok := smokeArgs[name]; ok {
argSets = custom
}
tool, _ := reg.Get(name)
for _, args := range argSets {
res := reg.Dispatch(context.Background(),
tools.Context{Principal: admin, RunID: "run_smoke"}, name, json.RawMessage(args))
// A write never reaches its handler here, because nothing has been
// approved. What is being smoke-tested is its RENDERER — which runs
// the same resolution queries the handler will, so a mistyped column
// in either is caught. The one thing that must not happen is data.
if tool.Effect == tools.EffectWrite {
if res.Data != nil {
t.Errorf("%s wrote without an approval", name)
}
continue
}
// knowledge_search is wired with no retriever and no corpus here, so
// refusing is the correct outcome. Asserted as a refusal rather than
// skipped, because the failure worth catching is it answering.
if name == "knowledge_search" {
if res.Data != nil {
t.Errorf("knowledge_search answered with no knowledge layer wired: %+v", res.Data)
}
continue
}
if res.Error != nil {
t.Errorf("%s with %s: %s — %s", name, args, res.Error.Code, res.Error.Message)
continue
}
if res.Data == nil {
t.Errorf("%s with %s: returned no data", name, args)
}
}
}
}
func TestEveryToolDeniesACallerWithNoTenant(t *testing.T) {
// I5, across the whole surface. One tool that forgot would be a
// cross-tenant read, so this asserts the property rather than the code.
h := testutil.New(t)
stranger := authctx.Identity{UserID: "u", Role: "admin", Email: "x@example.test"}
reg := tools.NewRegistry()
for _, tool := range everyTool(h.Pool) {
reg.MustRegister(tool)
}
for _, name := range reg.Names() {
res := reg.Dispatch(context.Background(),
tools.Context{Principal: stranger}, name, json.RawMessage(`{}`))
// A cross-domain tool reports withheld areas rather than refusing
// outright — it has nothing it may read, which is a different answer
// from "you may not ask". Either is acceptable; returning data is not.
if res.Error != nil {
continue
}
encoded, _ := json.Marshal(res.Data)
if !strings.Contains(string(encoded), "withheld") {
t.Errorf("%s answered a caller with no tenant: %s", name, encoded)
}
}
}
func TestEveryToolDeniesAnUnknownRole(t *testing.T) {
h := testutil.New(t)
stranger := authctx.Identity{
UserID: "u", OrgID: h.OrgID, Role: "superuser", Email: "x@example.test",
}
reg := tools.NewRegistry()
for _, tool := range everyTool(h.Pool) {
reg.MustRegister(tool)
}
for _, name := range reg.Names() {
res := reg.Dispatch(context.Background(),
tools.Context{Principal: stranger}, name, json.RawMessage(`{}`))
if res.Error != nil {
continue
}
encoded, _ := json.Marshal(res.Data)
if !strings.Contains(string(encoded), "withheld") {
t.Errorf("%s answered an unlisted role: %s", name, encoded)
}
}
}
func TestEveryToolIsDescribedAndEveryWriteCanExplainItself(t *testing.T) {
// This test used to assert that nothing wrote, with a note saying it must
// be changed deliberately when the first write landed. assign_worker is
// that write, so here is the deliberate change — and the property worth
// asserting now is not "no writes" but "every write can say what it does".
//
// Register() enforces the same thing at boot. Asserted again here because
// this list is what a reviewer reads to see the shape of the tool surface,
// and a write appearing in it with no renderer should fail loudly next to
// its peers rather than only inside a constructor.
writes := 0
for _, tool := range everyTool(nil) {
switch tool.Effect {
case tools.EffectRead:
if tool.Confirm != nil {
t.Errorf("%s is a read with a confirmation renderer that will never run", tool.Name)
}
case tools.EffectWrite:
writes++
if tool.Confirm == nil {
t.Errorf("%s writes but cannot describe what it would do", tool.Name)
}
default:
t.Errorf("%s declares effect %q, want read or write", tool.Name, tool.Effect)
}
if len(tool.Description) < 60 {
t.Errorf("%s has a %d-character description; the model reads this instead of docs",
tool.Name, len(tool.Description))
}
if tool.InputSchema == nil {
t.Errorf("%s has no input schema", tool.Name)
}
}
if writes != 2 {
t.Errorf("%d write tools; each one added is a new way for an agent to change the "+
"world, so update this count deliberately", writes)
}
}
func TestToolCountMatchesTheShippedRegistry(t *testing.T) {
// Guards the duplication in everyTool: a tool registered in the service
// but missing here would never be smoke-tested.
reg := tools.NewRegistry()
for _, tool := range everyTool(nil) {
reg.MustRegister(tool)
}
if got := len(reg.Names()); got != 19 {
t.Errorf("the registry holds %d tools; update this test and runtime.DefaultTools together", got)
}
}

View File

@@ -0,0 +1,504 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// Moving a candidate through the funnel: the platform's second write.
//
// It exists to replace a capability rather than to add one. The browser panel
// could already mark an interview ready, through a matcher that recognised the
// phrasing and called the app's own mutation — instant, free, and reachable
// only by the handful of sentences somebody wrote a pattern for. Routing past
// that machinery is only honest if nothing is lost, and this is the thing that
// would otherwise have been lost.
//
// The second write is also the first test of whether the confirmation
// mechanism GENERALISES. assign_worker could have been special-cased into the
// gate a dozen ways without anyone noticing. This tool shares every piece of it
// — the binding, the renderer contract, the single-use token, the replay — and
// adds none of its own.
/* ── Vocabulary ─────────────────────────────────────────────────────────── */
// applicationStages is the funnel, in order.
//
// From the `application_status` enum, and in the enum's own order, because
// "forward" and "backward" are only meaningful against a fixed sequence. The
// two terminal outcomes sit outside it: hiring and rejecting are decisions, not
// positions in a queue, and treating them as "further along" would let a
// request to advance somebody one step quietly hire them.
var applicationStages = []string{"applied", "ai_screened", "shortlisted", "interview"}
// terminalStages are the outcomes a candidate can be moved to from anywhere.
var terminalStages = []string{"hired", "rejected"}
// settledStages are the outcomes that take somebody out of the running.
//
// `assigned` appears here and nowhere else in this file's vocabulary: it is not
// a stage this tool may *set* (see knownStage), but a candidate already placed
// on a shift is not waiting on a decision either. Leaving it out of this list is
// how a settled candidate gets chased twice.
var settledStages = []string{"hired", "rejected", "assigned"}
// quotedList renders a package-level vocabulary as a SQL literal list. Never
// reachable from caller input — every caller passes one of the vars above.
func quotedList(vs []string) string {
out := make([]string, len(vs))
for i, v := range vs {
out[i] = "'" + v + "'"
}
return strings.Join(out, ", ")
}
// listableStage reports whether a stage can be asked for by name. Wider than
// knownStage: every status in the enum can be read, but `assigned` cannot be set.
func listableStage(s string) bool {
return knownStage(s) || s == "assigned"
}
// stageLabels are how a person reads a stage. The enum values are for the
// database; a confirmation dialog saying `ai_screened` is a dialog written for
// the schema rather than for the person approving it.
var stageLabels = map[string]string{
"applied": "Applied",
"ai_screened": "Screened",
"shortlisted": "Shortlisted",
"interview": "Interview",
"hired": "Hired",
"rejected": "Rejected",
"assigned": "Assigned",
}
func stageLabel(s string) string {
if l, ok := stageLabels[s]; ok {
return l
}
return s
}
// knownStage reports whether a stage is one this tool may set.
//
// `assigned` is deliberately absent: an application becomes assigned because
// somebody was put on a shift, which is assign_worker's business. Letting this
// tool set it would create a second path to the same state that writes no
// assignment row — a candidate marked assigned to nothing.
func knownStage(s string) bool {
for _, v := range applicationStages {
if v == s {
return true
}
}
for _, v := range terminalStages {
if v == s {
return true
}
}
return false
}
/* ── The tool ───────────────────────────────────────────────────────────── */
type moveApplicationInput struct {
ApplicationID string `json:"application_id"`
Stage string `json:"stage"`
Note string `json:"note"`
}
// MoveApplication advances or rejects a candidate.
func MoveApplication(db repo.Querier) Tool {
return Tool{
Name: "move_application",
Description: "Move a candidate to a different stage of the hiring funnel — screened, " +
"shortlisted, interview, hired or rejected. This changes a real record and the " +
"candidate's status in the product. Requires an application id from " +
"candidates_quality or hires_recent; never invent one. A person must approve " +
"before this takes effect.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"application_id": map[string]any{
"type": "string",
"description": "The application's id, exactly as a lookup returned it.",
},
"stage": map[string]any{
"type": "string",
"enum": []string{"ai_screened", "shortlisted", "interview", "hired", "rejected"},
"description": "Where to move them. Use 'interview' to mark someone ready to " +
"interview. 'hired' and 'rejected' are final outcomes.",
},
"note": map[string]any{
"type": "string",
"description": "Optional one-line reason, shown to the person approving. " +
"Say why this candidate and not the alternatives.",
},
},
"required": []string{"application_id", "stage"},
"additionalProperties": false,
},
Effect: EffectWrite,
MaxResultBytes: DefaultMaxResultBytes,
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
plan, denied := planMove(ctx, db, tc, inputs)
if denied != nil {
return nil, denied
}
details := []Detail{
{Label: "Candidate", Value: plan.name},
{Label: "Role", Value: plan.roleTitle},
{Label: "Moving", Value: fmt.Sprintf("%s → %s",
stageLabel(plan.currentStage), stageLabel(plan.stage))},
}
if plan.score > 0 {
details = append(details, Detail{
Label: "Match score", Value: fmt.Sprintf("%d", plan.score)})
}
if plan.note != "" {
details = append(details, Detail{Label: "Reason", Value: plan.note})
}
var warnings []string
// A terminal stage is the one a person most needs to be stopped on:
// it is the hardest to walk back, and the model reaching it early is
// the most expensive mistake available here.
switch plan.stage {
case "hired":
warnings = append(warnings, fmt.Sprintf(
"Hiring is a final outcome. %s will count as hired for this role.", plan.name))
case "rejected":
warnings = append(warnings, fmt.Sprintf(
"Rejecting is a final outcome. %s will be out of the running for this role.", plan.name))
}
// Skipping stages is legitimate — a strong candidate can go straight
// to interview — but it is worth pointing out, because a model
// misreading which stage somebody is at produces exactly this shape.
if plan.skipped > 1 {
warnings = append(warnings, fmt.Sprintf(
"This skips %d stage(s): %s is currently at %s.",
plan.skipped-1, plan.name, stageLabel(plan.currentStage)))
}
if plan.currentStage == plan.stage {
warnings = append(warnings, fmt.Sprintf(
"%s is already at %s. Approving this changes nothing.",
plan.name, stageLabel(plan.stage)))
}
summary := fmt.Sprintf("%s moves from %s to %s for %s.",
plan.name, stageLabel(plan.currentStage), stageLabel(plan.stage), plan.roleTitle)
if plan.stage == "interview" {
summary = fmt.Sprintf(
"%s will be marked ready to interview for %s, and will appear in the "+
"interview queue.", plan.name, plan.roleTitle)
}
return &Confirmation{
Title: fmt.Sprintf("Move %s to %s", plan.name, stageLabel(plan.stage)),
Summary: summary,
Details: details,
Warnings: warnings,
}, nil
},
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
plan, denied := planMove(ctx, db, tc, inputs)
if denied != nil {
return *denied
}
// Re-read and re-checked, like assign_worker: the approval was given
// against a picture some minutes old, and somebody else may have
// moved this candidate in between. Moving them again from a stage
// the approver never saw is not what they agreed to.
if plan.currentStage == plan.stage {
return OK(map[string]any{
"applicationId": plan.id,
"candidate": plan.name,
"stage": plan.stage,
"changed": false,
"note": fmt.Sprintf("%s was already at %s; nothing was changed.",
plan.name, stageLabel(plan.stage)),
})
}
// screened_at is deliberately not written. Migration 000003 dropped
// job_applications_screened_consistent because nothing in the product
// ever sets that column; writing it here would make this tool its only
// writer, so the column would come to mean "an agent touched this row"
// rather than what its name says. status is the screening record.
var updated string
err := db.QueryRow(ctx, `
UPDATE job_applications
SET status = $3::application_status,
updated_date = now()
WHERE id = $1::uuid AND org_id = $2::uuid
RETURNING status::text`,
plan.id, tc.OrgID(), plan.stage,
).Scan(&updated)
if err != nil {
return Failf(CodeFailed, "the candidate could not be moved")
}
return OK(map[string]any{
"applicationId": plan.id,
"candidate": plan.name,
"role": plan.roleTitle,
"from": plan.currentStage,
"stage": updated,
"changed": true,
"confirmed": true,
})
},
}
}
/* ── Resolution ─────────────────────────────────────────────────────────── */
type movePlan struct {
id string
name string
email string
roleTitle string
currentStage string
stage string
note string
score int
// skipped is how many stages forward this moves. 1 is the next one along;
// more than that jumps the queue, which is allowed and worth saying.
skipped int
}
// planMove authorizes, validates and resolves a move_application call.
//
// Shared by the renderer and the handler so the thing described and the thing
// done are resolved by identical code — the same reason assign_worker has
// planAssignment. Two resolutions would drift, and the drift lands exactly
// between what a person approved and what happened.
func planMove(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*movePlan, *Result) {
// Update, not List. `job-applications` lists to everyone — a talent caller
// may read their own — and updates for operators only. Asking the read
// question here would let a candidate advance themselves.
if _, denied := authorizeOp(tc, "job-applications", domain.OpUpdate, ""); denied != nil {
return nil, denied
}
var in moveApplicationInput
if err := json.Unmarshal(inputs, &in); err != nil {
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
return nil, &bad
}
id := strings.TrimSpace(in.ApplicationID)
stage := strings.TrimSpace(strings.ToLower(in.Stage))
if id == "" {
bad := Failf(CodeInvalidInput, "an application id is required")
return nil, &bad
}
if !knownStage(stage) {
bad := Failf(CodeInvalidInput,
"%q is not a stage; use ai_screened, shortlisted, interview, hired or rejected", in.Stage)
return nil, &bad
}
if stage == "applied" {
// Moving somebody back to the start is not a funnel action, it is an
// undo — and an undo that erases the record of having been screened.
bad := Failf(CodeInvalidInput, "a candidate cannot be moved back to applied")
return nil, &bad
}
plan := &movePlan{id: id, stage: stage, note: strings.TrimSpace(in.Note)}
// Behind the caller's own read predicate. Referencing an application this
// caller could not have read would confirm it exists.
q, denied := authorizeAs(tc, "job-applications", "a")
if denied != nil {
return nil, denied
}
q.eq("id::text", id)
if err := db.QueryRow(ctx, `
SELECT a.id::text, a.applicant_name, a.email::text, a.status::text, a.ai_score,
coalesce(nullif(p.title, ''), 'an unnamed role')
FROM job_applications a
JOIN job_postings p ON p.id = a.job_posting_id
WHERE `+q.clause(), q.args...,
).Scan(&plan.id, &plan.name, &plan.email, &plan.currentStage, &plan.score, &plan.roleTitle); err != nil {
denied := Denied()
return nil, &denied
}
if strings.TrimSpace(plan.name) == "" {
plan.name = plan.email
}
plan.skipped = stagesBetween(plan.currentStage, plan.stage)
return plan, nil
}
// stagesBetween counts how far forward a move goes.
//
// Zero for a terminal outcome or a move that is not forward along the funnel —
// there is no meaningful "distance" to rejecting somebody, and reporting one
// would produce a warning about skipping stages on a decision that skips
// nothing.
func stagesBetween(from, to string) int {
index := func(s string) int {
for i, v := range applicationStages {
if v == s {
return i
}
}
return -1
}
f, t := index(from), index(to)
if f < 0 || t < 0 || t <= f {
return 0
}
return t - f
}
/* ── Lookup ─────────────────────────────────────────────────────────────── */
type candidatesInput struct {
Stage string `json:"stage"`
Limit int `json:"limit"`
}
// CandidatesAwaiting lists candidates at a stage, with the ids to move them.
//
// §4: a tool whose schema demands an id the model was never given is a design
// bug, and the fix is a lookup rather than a friendlier error message. Every
// analytics tool in this package returns aggregates on purpose — an id in a
// count is noise — so move_application would be unusable without this.
//
// Ordered by match score. A person asking "who is waiting" almost always means
// "who should I look at first", and returning them in insertion order makes the
// model do ranking it has no basis for.
func CandidatesAwaiting(db repo.Querier) Tool {
return Tool{
Name: "candidates_awaiting",
Description: "List candidates currently at a given stage of the funnel — strongest " +
"match first — with the application id needed to move them. Use this before " +
"moving anyone: it is the only way to learn an application's id, and an id must " +
"never be guessed.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"stage": map[string]any{
"type": "string",
"enum": []string{"applied", "ai_screened", "shortlisted", "interview",
"hired", "rejected", "assigned"},
"description": "Which stage to list. Omit for everyone still in the running " +
"(that is, not hired, rejected or already assigned to a shift).",
},
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 50,
"description": "How many to list. Defaults to 15.",
},
},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "job-applications", "a")
if denied != nil {
return *denied
}
var in candidatesInput
if len(inputs) > 0 {
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
}
stage := strings.TrimSpace(strings.ToLower(in.Stage))
if stage != "" && !listableStage(stage) {
return Failf(CodeInvalidInput, "%q is not a stage", in.Stage)
}
if stage != "" {
q.eq("status::text", stage)
} else {
// Still in the running. Built from this tool's own vocabulary
// rather than a hand-written list, so a settled outcome added
// there cannot be left behind here.
q.raw("a.status NOT IN (" + quotedList(settledStages) + ")")
}
limit := in.Limit
if limit <= 0 {
limit = 15
}
if limit > 50 {
limit = 50
}
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx, `
SELECT a.id::text, a.applicant_name, a.email::text, a.status::text, a.ai_score,
coalesce(nullif(p.title, ''), 'an unnamed role'),
(a.status <> 'applied')
FROM job_applications a
JOIN job_postings p ON p.id = a.job_posting_id
WHERE `+q.clause()+`
ORDER BY a.ai_score DESC, a.created_date ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the candidates could not be read")
}
defer rows.Close()
candidates := []map[string]any{}
for rows.Next() {
var (
id, name, email, status, role string
score int
screened bool
)
if err := rows.Scan(&id, &name, &email, &status, &score, &role, &screened); err != nil {
return Failf(CodeFailed, "the candidates could not be read")
}
if strings.TrimSpace(name) == "" {
name = email
}
row := map[string]any{
"applicationId": id,
"name": name,
"role": role,
"stage": status,
"stageLabel": stageLabel(status),
"screened": screened,
}
// Absent rather than 0: a candidate nobody scored has no score,
// and reporting 0 invites the model to rank them as the worst.
if score > 0 {
row["matchScore"] = score
}
candidates = append(candidates, row)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the candidates could not be read")
}
data := map[string]any{
"candidates": candidates,
"count": len(candidates),
"stage": stage,
}
if stage == "" {
data["stage"] = "still in the running"
}
if len(candidates) == 0 {
data["note"] = "Nobody is at that stage. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}

View File

@@ -0,0 +1,474 @@
package tools_test
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/testutil"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// The second write tool.
//
// Half of what is asserted here is the same as assign_worker's, and that is the
// point rather than duplication: the confirmation gate was built once, and a
// second tool that has to re-earn "cannot write without approval" would mean it
// had been special-cased into the first. It is not — this tool declares an
// effect and a renderer, and everything else comes from the registry.
//
// The other half is this tool's own: a funnel has an order, terminal outcomes
// are different from steps along it, and moving somebody backwards is not a
// move at all.
type funnelFixture struct {
orgID string
admin authctx.Identity
talent authctx.Identity
appID string
name string
}
func seedFunnel(t *testing.T, h *testutil.Harness, slug string) funnelFixture {
t.Helper()
ctx := context.Background()
org := freshOrg(t, h, slug)
f := funnelFixture{orgID: org, name: "Dana Okonkwo"}
f.admin = authctx.Identity{
UserID: seedUser(t, h, org, fmt.Sprintf("boss-%s@example.test", slug), "admin"),
OrgID: org, Role: "admin", Email: fmt.Sprintf("boss-%s@example.test", slug),
}
candidateEmail := fmt.Sprintf("dana-%s@example.test", slug)
f.talent = authctx.Identity{
UserID: seedUser(t, h, org, candidateEmail, "talent"),
OrgID: org, Role: "talent", Email: candidateEmail,
}
var postingID string
if err := h.Pool.QueryRow(ctx, `
INSERT INTO job_postings (org_id, title, status, headcount)
VALUES ($1::uuid, 'Sous Chef', 'active', 1) RETURNING id::text`, org).Scan(&postingID); err != nil {
t.Fatalf("seed posting: %v", err)
}
if err := h.Pool.QueryRow(ctx, `
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
VALUES ($1::uuid, $2::uuid, $3, $4, 'ai_screened', 88) RETURNING id::text`,
org, postingID, f.name, candidateEmail).Scan(&f.appID); err != nil {
t.Fatalf("seed application: %v", err)
}
return f
}
func stageOf(t *testing.T, h *testutil.Harness, appID string) string {
t.Helper()
var s string
if err := h.Pool.QueryRow(context.Background(),
`SELECT status::text FROM job_applications WHERE id = $1::uuid`, appID).Scan(&s); err != nil {
t.Fatalf("read stage: %v", err)
}
return s
}
func moveArgs(appID, stage string) string {
return fmt.Sprintf(`{"application_id":%q,"stage":%q}`, appID, stage)
}
/* ── The gate, again ────────────────────────────────────────────────────── */
func TestMovingACandidateNeedsApproval(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-gate")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: f.admin, RunID: "run_m1"}
args := moveArgs(f.appID, "interview")
res := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
if got := stageOf(t, h, f.appID); got != "ai_screened" {
t.Fatalf("the candidate moved to %q before anyone approved anything", got)
}
// Names and readable stages, not enum values and uuids.
body, _ := json.Marshal(res.Confirmation)
for _, want := range []string{"Dana Okonkwo", "Sous Chef", "Interview"} {
if !strings.Contains(string(body), want) {
t.Errorf("the confirmation does not mention %q: %s", want, body)
}
}
if strings.Contains(res.Confirmation.Title, "ai_screened") {
t.Error("the confirmation shows an enum value where it should show a label")
}
tc.Confirmation = res.Confirmation.Token
if out := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args)); out.Error != nil {
t.Fatalf("an approved move should run: %+v", out.Error)
}
if got := stageOf(t, h, f.appID); got != "interview" {
t.Fatalf("stage is %q after approval, want interview", got)
}
}
func TestACandidateCannotMoveThemselves(t *testing.T) {
// `job-applications` lists to everyone — a talent caller may read their own
// — and updates for operators only. Asking the policy the READ question
// here would let a candidate advance themselves to interview.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-self")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.talent, RunID: "run_m2"},
"move_application", json.RawMessage(moveArgs(f.appID, "interview")))
if res.Confirmation != nil {
t.Fatal("a candidate was asked to approve their own advancement")
}
if res.Error == nil || res.Error.Code != tools.CodeDenied {
t.Fatalf("want the standard denial, got %+v", res.Error)
}
if got := stageOf(t, h, f.appID); got != "ai_screened" {
t.Fatalf("a candidate moved themselves to %q", got)
}
}
func TestAnotherTenantsCandidateIsAbsentRatherThanForbidden(t *testing.T) {
h := testutil.New(t)
mine := seedFunnel(t, h, "funnel-mine")
theirs := seedFunnel(t, h, "funnel-theirs")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: mine.admin, RunID: "run_m3"}
real := reg.Dispatch(context.Background(), tc, "move_application",
json.RawMessage(moveArgs(theirs.appID, "interview")))
fake := reg.Dispatch(context.Background(), tc, "move_application",
json.RawMessage(moveArgs("00000000-0000-0000-0000-0000000000ff", "interview")))
if real.Error == nil || fake.Error == nil {
t.Fatal("a cross-tenant or invented application id was accepted")
}
if real.Error.Code != fake.Error.Code || real.Error.Message != fake.Error.Message {
t.Errorf("a real-but-forbidden candidate is distinguishable from an imaginary one:\n"+
" other tenant: %s\n invented: %s", real.Error.Message, fake.Error.Message)
}
if got := stageOf(t, h, theirs.appID); got != "ai_screened" {
t.Fatal("a write crossed a tenant boundary")
}
}
/* ── The funnel's own rules ─────────────────────────────────────────────── */
func TestATerminalOutcomeIsWarnedAbout(t *testing.T) {
// Hiring and rejecting are the hardest decisions to walk back, and a model
// reaching them early is the most expensive mistake available here. They
// get a warning above the button, not a footnote.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-terminal")
reg := liveRegistry(t, h)
for stage, word := range map[string]string{"hired": "final outcome", "rejected": "final outcome"} {
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m4"},
"move_application", json.RawMessage(moveArgs(f.appID, stage)))
if res.Confirmation == nil {
t.Fatalf("%s: expected a confirmation, got %+v", stage, res)
}
var found bool
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, word) {
found = true
}
}
if !found {
t.Errorf("moving to %s was described without warning it is final: %v",
stage, res.Confirmation.Warnings)
}
}
}
func TestSkippingStagesIsWarnedAbout(t *testing.T) {
// Legitimate — a strong candidate can go straight to interview — but a
// model misreading which stage somebody is at produces exactly this shape,
// so the person approving should be told.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-skip")
reg := liveRegistry(t, h)
// ai_screened → interview skips shortlisted.
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m5"},
"move_application", json.RawMessage(moveArgs(f.appID, "interview")))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
var found bool
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, "skips") {
found = true
}
}
if !found {
t.Errorf("skipping a stage was not mentioned: %v", res.Confirmation.Warnings)
}
}
func TestACandidateCannotBeMovedBackToApplied(t *testing.T) {
// Not a funnel action but an undo — and one that would erase the record of
// having been screened. Refused as invalid input rather than described.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-back")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m6"},
"move_application", json.RawMessage(moveArgs(f.appID, "applied")))
if res.Confirmation != nil {
t.Fatal("moving a candidate backwards was offered for approval")
}
if res.Error == nil || res.Error.Code != tools.CodeInvalidInput {
t.Fatalf("want invalid input, got %+v", res.Error)
}
}
func TestMovingSomebodyToWhereTheyAlreadyAreChangesNothing(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-noop")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: f.admin, RunID: "run_m7"}
args := moveArgs(f.appID, "ai_screened")
res := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
// Said out loud rather than silently doing nothing.
var warned bool
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, "changes nothing") {
warned = true
}
}
if !warned {
t.Errorf("a no-op move was not flagged as one: %v", res.Confirmation.Warnings)
}
tc.Confirmation = res.Confirmation.Token
out := reg.Dispatch(context.Background(), tc, "move_application", json.RawMessage(args))
if out.Error != nil {
t.Fatalf("a no-op move should succeed: %+v", out.Error)
}
body, _ := json.Marshal(out.Data)
if !strings.Contains(string(body), `"changed":false`) {
t.Errorf("a no-op did not report itself as one: %s", body)
}
}
/* ── The lookup ─────────────────────────────────────────────────────────── */
func TestCandidatesAwaitingReturnsIdsAndReadableStages(t *testing.T) {
// §4: a tool that requires the model to guess an id is a design bug. This
// is the lookup that makes move_application usable without guessing.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-lookup")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m8"},
"candidates_awaiting", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
}
body, _ := json.Marshal(res.Data)
for _, want := range []string{f.appID, "Dana Okonkwo", "Screened", "matchScore"} {
if !strings.Contains(string(body), want) {
t.Errorf("the lookup does not carry %q: %s", want, body)
}
}
}
func TestCandidatesAwaitingExcludesSettledCandidates(t *testing.T) {
// "Still in the running" is the useful default: a person asking who is
// waiting does not mean the people already hired or rejected.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-settled")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: f.admin, RunID: "run_m9"}
if _, err := h.Pool.Exec(context.Background(),
`UPDATE job_applications SET status = 'rejected' WHERE id = $1::uuid`, f.appID); err != nil {
t.Fatalf("settle: %v", err)
}
res := reg.Dispatch(context.Background(), tc, "candidates_awaiting", json.RawMessage(`{}`))
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.appID) {
t.Errorf("a rejected candidate was listed as still in the running: %s", body)
}
// But asked for explicitly, they are there.
res = reg.Dispatch(context.Background(), tc, "candidates_awaiting", json.RawMessage(`{"stage":"rejected"}`))
if body, _ := json.Marshal(res.Data); !strings.Contains(string(body), f.appID) {
t.Errorf("asking for rejected candidates did not return one: %s", body)
}
}
func TestScreenedIsDerivedFromStageNotFromAVestigialColumn(t *testing.T) {
// A live run reported five candidates sitting at the Screened stage all
// carrying screened:false, and read it as the stage label running ahead of
// the work. The data was fine; the tool was wrong. `screened` was read from
// job_applications.screened_at, a column migration 000003 established that
// nothing in the product ever writes — so it was null for every real row and
// the flag was false for everyone, forever. The product's own definition,
// in eight places, is status = 'applied'. This asserts that definition.
//
// The old test fixture hid this by inserting screened_at itself, which no
// production path does; the seed below deliberately does not.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-screened")
reg := liveRegistry(t, h)
ctx := context.Background()
var freshID string
if err := h.Pool.QueryRow(ctx, `
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
SELECT org_id, job_posting_id, 'Ivo Brandt', 'ivo-screened@example.test', 'applied', 0
FROM job_applications WHERE id = $1::uuid
RETURNING id::text`, f.appID).Scan(&freshID); err != nil {
t.Fatalf("seed applied candidate: %v", err)
}
res := reg.Dispatch(ctx,
tools.Context{Principal: f.admin, RunID: "run_m10"},
"candidates_awaiting", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
}
var payload struct {
Candidates []struct {
ID string `json:"applicationId"`
Screened bool `json:"screened"`
} `json:"candidates"`
}
body, _ := json.Marshal(res.Data)
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
seen := map[string]bool{}
for _, c := range payload.Candidates {
seen[c.ID] = c.Screened
}
if got, ok := seen[f.appID]; !ok {
t.Fatalf("the ai_screened candidate was not returned: %s", body)
} else if !got {
t.Errorf("a candidate at the Screened stage reported screened:false — " +
"the flag is being read from something other than the stage")
}
if got, ok := seen[freshID]; !ok {
t.Fatalf("the applied candidate was not returned: %s", body)
} else if got {
t.Errorf("a candidate still at Applied reported screened:true")
}
}
func TestMovingACandidateDoesNotWriteTheVestigialColumn(t *testing.T) {
// Keeping this tool as the sole writer of screened_at would quietly redefine
// the column to mean "an agent touched this row". status carries the stage
// and the trajectory carries the who and when.
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-vestigial")
reg := liveRegistry(t, h)
ctx := context.Background()
tc := tools.Context{Principal: f.admin, RunID: "run_m11"}
args := moveArgs(f.appID, "interview")
res := reg.Dispatch(ctx, tc, "move_application", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
out, ok := reg.DispatchApproved(ctx, tc, res.Confirmation.Token)
if !ok {
t.Fatal("a valid token could not be redeemed")
}
if out.Result.Error != nil {
t.Fatalf("approved move failed: %+v", out.Result.Error)
}
if got := stageOf(t, h, f.appID); got != "interview" {
t.Fatalf("the approved move did not land: stage is %q", got)
}
var written bool
if err := h.Pool.QueryRow(ctx,
`SELECT screened_at IS NOT NULL FROM job_applications WHERE id = $1::uuid`,
f.appID).Scan(&written); err != nil {
t.Fatalf("read screened_at: %v", err)
}
if written {
t.Errorf("move_application wrote screened_at, making it the column's only writer")
}
}
// A candidate placed on a shift is not waiting on a decision. `assigned` is in
// the application_status enum but was in neither of this file's stage lists, so
// the "still in the running" predicate — written as NOT IN ('hired','rejected')
// — let them through, and candidates_awaiting listed somebody already working
// as somebody to chase. The same omission on the frontend dropped `assigned`
// out of every funnel bucket; here it fell into the wrong one instead.
func TestAnAssignedCandidateIsNotStillInTheRunning(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-assigned")
reg := liveRegistry(t, h)
ctx := context.Background()
tc := tools.Context{Principal: f.admin, RunID: "run_m12"}
if _, err := h.Pool.Exec(ctx,
`UPDATE job_applications SET status = 'assigned' WHERE id = $1::uuid`, f.appID); err != nil {
t.Fatalf("assign: %v", err)
}
res := reg.Dispatch(ctx, tc, "candidates_awaiting", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("candidates_awaiting failed: %+v", res.Error)
}
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.appID) {
t.Errorf("a candidate already assigned to a shift was listed as awaiting a decision: %s", body)
}
// Asked for by name they are there — the read tool can show every status,
// even the one move_application is not allowed to set.
res = reg.Dispatch(ctx, tc, "candidates_awaiting", json.RawMessage(`{"stage":"assigned"}`))
if res.Error != nil {
t.Fatalf("listing assigned candidates failed: %+v", res.Error)
}
body, _ := json.Marshal(res.Data)
if !strings.Contains(string(body), f.appID) {
t.Errorf("asking for assigned candidates did not return one: %s", body)
}
if !strings.Contains(string(body), "Assigned") {
t.Errorf("the stage label is not readable: %s", body)
}
}
// move_application must still refuse to *set* assigned: that state means an
// assignment row exists, and this tool writes none.
func TestMoveApplicationStillRefusesToSetAssigned(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "funnel-noassign")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_m13"},
"move_application", json.RawMessage(moveArgs(f.appID, "assigned")))
if res.Error == nil {
t.Fatalf("move_application accepted 'assigned'; it would mark a candidate assigned to nothing: %+v", res)
}
if res.Confirmation != nil {
t.Errorf("it even offered a confirmation for it")
}
}

View File

@@ -0,0 +1,595 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/krow/krow-backend/go-api/internal/domain"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// Assignments: the first tools that change something, and the two lookups that
// make changing something possible.
//
// §4 has a line that dictates the shape of this file: "a tool description that
// requires the model to guess an ID it has not been given is a design bug. Add
// a lookup tool instead." Every read tool built so far returns aggregates —
// counts, averages, the weakest five — and none of them return a row id,
// deliberately: an id in an analytics answer is noise. But an assignment names
// a posting and a person, so the write is unusable until the model has a
// legitimate way to learn those two things.
//
// Hence three tools, in the order an agent actually uses them:
//
// open_positions → which roles need people, with their ids
// available_workers → who is free in that window, with their emails
// assign_worker → put one on the other, once a person has said yes
//
// The alternative — a single write that accepts a worker's name and resolves it
// itself — reads as friendlier and is considerably worse. Two people called
// Chen makes it ambiguous, and the disambiguation would happen inside a write,
// after approval, with no one watching.
/* ── Lookup: open positions ─────────────────────────────────────────────── */
type openPositionsInput struct {
Limit int `json:"limit"`
}
// OpenPositions lists roles that still need people, with the ids to fill them.
func OpenPositions(db repo.Querier) Tool {
return Tool{
Name: "open_positions",
Description: "List the roles that are currently open, with how many people each " +
"needs, how many are already assigned, and how many are still to fill. " +
"Returns an id for each role. Use this before assigning anyone: it is the " +
"only way to learn a role's id, and an id must never be guessed.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": "How many roles to list, most urgent first. Defaults to 20.",
},
},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "job-postings", "p")
if denied != nil {
return *denied
}
var in openPositionsInput
if len(inputs) > 0 {
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
}
limit := in.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
// Only roles that can actually be staffed. A draft has not been
// agreed, a paused role has been stopped on purpose, and a closed
// one is history — offering any of them as assignable would invite
// the agent to staff a role nobody is hiring for.
q.raw("p.status = 'active'")
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx, `
SELECT p.id::text, p.title, p.role_category, p.location,
p.headcount, p.priority::text, p.start_date,
(SELECT count(*) FROM assignments a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id
AND a.status = 'active')
FROM job_postings p
WHERE `+q.clause()+`
ORDER BY p.priority DESC, p.created_date ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
defer rows.Close()
positions := []map[string]any{}
for rows.Next() {
var (
id, title, category, location, priority string
headcount int
startDate *time.Time
assigned int
)
if err := rows.Scan(&id, &title, &category, &location,
&headcount, &priority, &startDate, &assigned); err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
stillToFill := headcount - assigned
if stillToFill < 0 {
stillToFill = 0
}
p := map[string]any{
"id": id, "title": title, "priority": priority,
"headcount": headcount, "assigned": assigned, "stillToFill": stillToFill,
}
if category != "" {
p["roleCategory"] = category
}
if location != "" {
p["location"] = location
}
if startDate != nil {
p["startDate"] = startDate.Format("2006-01-02")
}
positions = append(positions, p)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the open roles could not be read")
}
data := map[string]any{"positions": positions, "count": len(positions)}
if len(positions) == 0 {
data["note"] = "No roles are open. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Lookup: available workers ──────────────────────────────────────────── */
type availableWorkersInput struct {
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
Limit int `json:"limit"`
}
// AvailableWorkers lists workers with no clashing assignment in a window.
//
// "Available" here means one specific, checkable thing: no active assignment
// overlapping the window. It does not mean willing, qualified, or within their
// contracted hours. The description says so, because a model given a tool
// called `available_workers` will otherwise report its output as availability
// in the ordinary sense of the word, and a manager will read it that way.
func AvailableWorkers(db repo.Querier) Tool {
return Tool{
Name: "available_workers",
Description: "List workers who have no clashing assignment in a given window, " +
"best-scoring first, with the email needed to assign them. Availability here " +
"means only that nothing else is booked over that window — it does not mean " +
"the person has agreed, is qualified for the role, or is within their hours. " +
"Say so when reporting it.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"starts_at": map[string]any{
"type": "string",
"description": "When the work starts, as an RFC 3339 timestamp " +
"(for example 2026-09-12T18:00:00Z). Required.",
},
"ends_at": map[string]any{
"type": "string",
"description": "When the work ends, as an RFC 3339 timestamp. " +
"Omit for open-ended work.",
},
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": "How many workers to list. Defaults to 10.",
},
},
"required": []string{"starts_at"},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "worker-profiles", "w")
if denied != nil {
return *denied
}
var in availableWorkersInput
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
if bad != nil {
return *bad
}
limit := in.Limit
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
// The clash test is a NOT EXISTS against the same tenant, so it is
// a pre-filter like every other predicate here rather than a list
// fetched and then thinned in Go.
args := append(append([]any{}, q.args...), starts, ends, limit)
startIdx, endIdx, limIdx := len(args)-2, len(args)-1, len(args)
rows, err := db.Query(ctx, fmt.Sprintf(`
SELECT w.full_name, w.email::text, nullif(w.krow_score, 0),
nullif(w.reliability_score, 0), nullif(w.experience_years, 0),
w.current_position
FROM worker_profiles w
WHERE %s
AND NOT EXISTS (
SELECT 1 FROM assignments a
WHERE a.org_id = w.org_id
AND a.worker_email = w.email
AND a.status = 'active'
AND tstzrange(a.starts_at, coalesce(a.ends_at, 'infinity'::timestamptz))
&& tstzrange($%d, coalesce($%d::timestamptz, 'infinity'::timestamptz)))
ORDER BY w.krow_score DESC NULLS LAST, w.reliability_score DESC NULLS LAST
LIMIT $%d`, q.clause(), startIdx, endIdx, limIdx), args...)
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
defer rows.Close()
workers := []map[string]any{}
for rows.Next() {
// Pointers, so an unrated worker carries no rating rather than a
// 0 the model would read as the worst possible score. This list
// feeds assign_worker, so the distinction picks who gets offered.
var (
name, email, position string
krow, reliability, experience *int
)
if err := rows.Scan(&name, &email, &krow, &reliability, &experience, &position); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
w := map[string]any{"name": name, "email": email}
if krow != nil {
w["krowScore"] = *krow
}
if reliability != nil {
w["reliabilityScore"] = *reliability
}
if experience != nil {
w["experienceYears"] = *experience
}
if position != "" {
w["currentPosition"] = position
}
workers = append(workers, w)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
data := map[string]any{
"window": windowLabel(starts, ends),
"workers": workers,
"count": len(workers),
"meaning": "No clashing assignment in this window. Not a statement that " +
"they have agreed or are qualified.",
}
if len(workers) == 0 {
data["note"] = "Nobody is free in that window. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Write: assign a worker ─────────────────────────────────────────────── */
type assignWorkerInput struct {
JobPostingID string `json:"job_posting_id"`
WorkerEmail string `json:"worker_email"`
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
}
// AssignWorker puts a worker on a role. The first tool in this service that
// changes anything.
//
// The pattern every future write should copy is the split between Confirm and
// Handler, and specifically what is duplicated across them. Both authorize.
// Both resolve the posting and the worker. Both check for a clash. That looks
// like repetition and is not: the renderer runs to describe, and the handler
// runs after a person has read that description and agreed — with an unbounded
// gap in between, during which somebody else may have taken the same shift.
//
// So the renderer's clash check produces a WARNING, and the handler's produces
// a REFUSAL. A description is about the moment it was written; a write is about
// the moment it happens.
func AssignWorker(db repo.Querier) Tool {
return Tool{
Name: "assign_worker",
Description: "Assign a worker to an open role for a given period. This creates a " +
"real assignment: the person is scheduled to work. Requires a role id from " +
"open_positions and a worker email from available_workers — never invent " +
"either. A person must approve before this takes effect.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"job_posting_id": map[string]any{
"type": "string",
"description": "The role's id, exactly as returned by open_positions.",
},
"worker_email": map[string]any{
"type": "string",
"description": "The worker's email, exactly as returned by available_workers.",
},
"starts_at": map[string]any{
"type": "string",
"description": "When the work starts, as an RFC 3339 timestamp. Required.",
},
"ends_at": map[string]any{
"type": "string",
"description": "When the work ends, as an RFC 3339 timestamp. Omit for open-ended.",
},
},
"required": []string{"job_posting_id", "worker_email", "starts_at"},
"additionalProperties": false,
},
Effect: EffectWrite,
MaxResultBytes: DefaultMaxResultBytes,
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
plan, denied := planAssignment(ctx, db, tc, inputs)
if denied != nil {
return nil, denied
}
details := []Detail{
{Label: "Worker", Value: plan.workerName},
{Label: "Role", Value: plan.postingTitle},
{Label: "Period", Value: windowLabel(plan.starts, plan.ends)},
}
if plan.location != "" {
details = append(details, Detail{Label: "Location", Value: plan.location})
}
details = append(details, Detail{
Label: "Role filled",
Value: fmt.Sprintf("%d of %d, this would make %d",
plan.assigned, plan.headcount, plan.assigned+1),
})
var warnings []string
if plan.clashes > 0 {
warnings = append(warnings, fmt.Sprintf(
"%s already has %s over this period. Assigning them will double-book.",
plan.workerName, plural(plan.clashes, "assignment", "assignments")))
}
if plan.assigned >= plan.headcount {
warnings = append(warnings, fmt.Sprintf(
"%s already has all %s it asked for. This would go over headcount.",
plan.postingTitle, plural(plan.headcount, "person", "people")))
}
if plan.starts.Before(time.Now()) {
warnings = append(warnings, "This period starts in the past.")
}
return &Confirmation{
Title: fmt.Sprintf("Assign %s to %s", plan.workerName, plan.postingTitle),
Summary: fmt.Sprintf(
"%s will be scheduled to work %s as %s. They will appear on the roster "+
"for this role and count towards its headcount.",
plan.workerName, windowLabel(plan.starts, plan.ends), plan.postingTitle),
Details: details,
Warnings: warnings,
}, nil
},
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
plan, denied := planAssignment(ctx, db, tc, inputs)
if denied != nil {
return *denied
}
// Re-checked here, not merely described above. The approval was
// given against a picture of the world that is now some minutes
// old, and a double-booking created between the asking and the
// answering is one nobody agreed to.
if plan.clashes > 0 {
return Failf(CodeFailed,
"%s was booked over this period since this was approved; nothing was assigned",
plan.workerName)
}
var id string
err := db.QueryRow(ctx, `
INSERT INTO assignments
(org_id, job_posting_id, worker_profile_id, worker_email,
worker_name, starts_at, ends_at, status, source, match_score)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, 'active', 'agent', $8)
RETURNING id::text`,
tc.OrgID(), plan.postingID, plan.workerProfileID, plan.workerEmail,
plan.workerName, plan.starts, nullableTime(plan.ends), plan.matchScore,
).Scan(&id)
if err != nil {
return Failf(CodeFailed, "the assignment could not be created")
}
return OK(map[string]any{
"assignmentId": id,
"worker": plan.workerName,
"role": plan.postingTitle,
"period": windowLabel(plan.starts, plan.ends),
"status": "active",
"confirmed": true,
})
},
}
}
// assignmentPlan is everything both Confirm and Handler need, resolved once.
type assignmentPlan struct {
postingID string
postingTitle string
location string
headcount int
assigned int
workerProfileID *string
workerEmail string
workerName string
matchScore *int
starts time.Time
ends *time.Time
clashes int
}
// planAssignment authorizes, validates and resolves an assign_worker call.
//
// Shared by the renderer and the handler so that the thing described and the
// thing done are resolved by the same code. Two separate resolutions would
// drift, and the drift would land exactly where nobody looks: between what a
// person approved and what then happened.
//
// Every refusal is the same opaque Denied(). A posting id that belongs to
// another tenant, one that does not exist, and one this caller may not see are
// all indistinguishable — otherwise assign_worker becomes a way to ask whether
// a given uuid is real.
func planAssignment(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*assignmentPlan, *Result) {
// Create, not List. `assignments` lists to everyone and creates for
// operators only, so a talent caller is refused here even though they may
// read their own roster perfectly well.
if _, denied := authorizeOp(tc, "assignments", domain.OpCreate, ""); denied != nil {
return nil, denied
}
var in assignWorkerInput
if err := json.Unmarshal(inputs, &in); err != nil {
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
return nil, &bad
}
if strings.TrimSpace(in.JobPostingID) == "" || strings.TrimSpace(in.WorkerEmail) == "" {
bad := Failf(CodeInvalidInput, "a role id and a worker email are both required")
return nil, &bad
}
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
if bad != nil {
return nil, bad
}
plan := &assignmentPlan{starts: starts, ends: ends}
// The posting, behind the caller's own read predicate. Referencing a row
// the caller could not have read would confirm it exists.
pq, denied := authorizeAs(tc, "job-postings", "p")
if denied != nil {
return nil, denied
}
pq.eq("id::text", strings.TrimSpace(in.JobPostingID))
pq.raw("p.status = 'active'")
err := db.QueryRow(ctx, `
SELECT p.id::text, p.title, p.location, p.headcount,
(SELECT count(*) FROM assignments a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.status = 'active')
FROM job_postings p
WHERE `+pq.clause(), pq.args...,
).Scan(&plan.postingID, &plan.postingTitle, &plan.location, &plan.headcount, &plan.assigned)
if err != nil {
denied := Denied()
return nil, &denied
}
// The worker, likewise.
wq, denied := authorizeAs(tc, "worker-profiles", "w")
if denied != nil {
return nil, denied
}
wq.eq("email", strings.TrimSpace(in.WorkerEmail))
var profileID string
if err := db.QueryRow(ctx, `
SELECT w.id::text, w.full_name, w.email::text, nullif(w.krow_score, 0)
FROM worker_profiles w
WHERE `+wq.clause()+`
LIMIT 1`, wq.args...,
).Scan(&profileID, &plan.workerName, &plan.workerEmail, &plan.matchScore); err != nil {
denied := Denied()
return nil, &denied
}
plan.workerProfileID = &profileID
if strings.TrimSpace(plan.workerName) == "" {
plan.workerName = plan.workerEmail
}
// Clashes: active assignments overlapping the window. Counted in SQL —
// fetching the person's roster and comparing in Go would be the same
// post-filter I2 forbids, and would read rows this call has no reason to.
if err := db.QueryRow(ctx, `
SELECT count(*) FROM assignments
WHERE org_id = $1::uuid AND worker_email = $2 AND status = 'active'
AND tstzrange(starts_at, coalesce(ends_at, 'infinity'::timestamptz))
&& tstzrange($3, coalesce($4::timestamptz, 'infinity'::timestamptz))`,
tc.OrgID(), plan.workerEmail, starts, nullableTime(ends),
).Scan(&plan.clashes); err != nil {
failed := Failf(CodeFailed, "the worker's existing assignments could not be read")
return nil, &failed
}
return plan, nil
}
/* ── Shared helpers ─────────────────────────────────────────────────────── */
// decodeWindow parses a caller-supplied period.
//
// RFC 3339 only, and validated here rather than at the database. A timestamp
// that reaches SQL as an uninterpretable string is a constraint violation
// wearing the costume of a tool failure, and the model cannot correct what it
// cannot read.
func decodeWindow(startsAt, endsAt string) (time.Time, *time.Time, *Result) {
starts, err := time.Parse(time.RFC3339, strings.TrimSpace(startsAt))
if err != nil {
bad := Failf(CodeInvalidInput,
"starts_at must be an RFC 3339 timestamp, for example 2026-09-12T18:00:00Z")
return time.Time{}, nil, &bad
}
if strings.TrimSpace(endsAt) == "" {
return starts, nil, nil
}
ends, err := time.Parse(time.RFC3339, strings.TrimSpace(endsAt))
if err != nil {
bad := Failf(CodeInvalidInput,
"ends_at must be an RFC 3339 timestamp, for example 2026-09-12T23:00:00Z")
return time.Time{}, nil, &bad
}
// The table has a CHECK for this. Caught here so the model gets a sentence
// it can act on rather than a constraint name it cannot.
if !ends.After(starts) {
bad := Failf(CodeInvalidInput, "ends_at must be after starts_at")
return time.Time{}, nil, &bad
}
return starts, &ends, nil
}
// windowLabel renders a period the way a person reads one.
func windowLabel(starts time.Time, ends *time.Time) string {
if ends == nil {
return starts.Format("Mon 2 Jan 2006, 15:04") + " onwards"
}
if ends.YearDay() == starts.YearDay() && ends.Year() == starts.Year() {
return fmt.Sprintf("%s–%s",
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("15:04"))
}
return fmt.Sprintf("%s – %s",
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("Mon 2 Jan 2006, 15:04"))
}
func nullableTime(t *time.Time) any {
if t == nil {
return nil
}
return *t
}
func plural(n int, one, many string) string {
if n == 1 {
return fmt.Sprintf("1 %s", one)
}
return fmt.Sprintf("%d %s", n, many)
}

View File

@@ -0,0 +1,443 @@
package tools_test
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/testutil"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// The write path, end to end, against the real schema and the real store.
//
// Everything in confirm_test.go is about the mechanism and runs against a spy.
// This file is about the one tool that uses it, and the assertion that matters
// throughout is the same one: count the rows in `assignments`. A test that only
// checks what Dispatch returned cannot tell a refusal that wrote from a refusal
// that did not.
type assignFixture struct {
orgID string
admin authctx.Identity
talent authctx.Identity
postingID string
worker string
starts time.Time
ends time.Time
}
func seedAssignable(t *testing.T, h *testutil.Harness, slug string) assignFixture {
t.Helper()
ctx := context.Background()
org := freshOrg(t, h, slug)
// Emails are globally unique, not merely unique per organization, so every
// fixture scopes its own by slug. Two tenants in one test would otherwise
// collide on the second seed.
boss := fmt.Sprintf("boss-%s@example.test", slug)
f := assignFixture{
orgID: org,
worker: fmt.Sprintf("maya-%s@example.test", slug),
starts: time.Date(2030, 9, 12, 18, 0, 0, 0, time.UTC),
ends: time.Date(2030, 9, 12, 23, 0, 0, 0, time.UTC),
}
// Real user rows, not invented uuids. agent_confirmations references
// users(id), so a synthetic principal cannot have a confirmation issued for
// it — which is correct (a confirmation is asked OF somebody) and means the
// fixture has to be honest about who is asking.
f.admin = authctx.Identity{
UserID: seedUser(t, h, org, boss, "admin"),
OrgID: org, Role: "admin", Email: boss,
}
f.talent = authctx.Identity{
UserID: seedUser(t, h, org, f.worker, "talent"),
OrgID: org, Role: "talent", Email: f.worker,
}
if err := h.Pool.QueryRow(ctx, `
INSERT INTO job_postings (org_id, title, status, headcount, location)
VALUES ($1::uuid, 'Bar Supervisor', 'active', 2, 'Shoreditch')
RETURNING id::text`, org).Scan(&f.postingID); err != nil {
t.Fatalf("seed posting: %v", err)
}
if _, err := h.Pool.Exec(ctx, `
INSERT INTO worker_profiles (org_id, full_name, email, krow_score)
VALUES ($1::uuid, 'Maya Chen', $2, 88)`, org, f.worker); err != nil {
t.Fatalf("seed worker: %v", err)
}
return f
}
func seedUser(t *testing.T, h *testutil.Harness, org, email, role string) string {
t.Helper()
var id string
if err := h.Pool.QueryRow(context.Background(), `
INSERT INTO users (org_id, email, full_name, role)
VALUES ($1::uuid, $2, $3, $4) RETURNING id::text`,
org, email, email, role).Scan(&id); err != nil {
t.Fatalf("seed user %s: %v", email, err)
}
return id
}
// liveRegistry is the shipped tool set over the real Postgres confirmation
// store — the wiring the service actually runs, not the in-memory stand-in.
func liveRegistry(t *testing.T, h *testutil.Harness) *tools.Registry {
t.Helper()
reg := tools.NewRegistryWithStore(tools.NewPostgresStore(h.Pool))
for _, tool := range everyTool(h.Pool) {
reg.MustRegister(tool)
}
return reg
}
func assignmentCount(t *testing.T, h *testutil.Harness, org string) int {
t.Helper()
var n int
if err := h.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM assignments WHERE org_id = $1::uuid`, org).Scan(&n); err != nil {
t.Fatalf("count assignments: %v", err)
}
return n
}
func assignArgs(f assignFixture) string {
return fmt.Sprintf(`{"job_posting_id":%q,"worker_email":%q,"starts_at":%q,"ends_at":%q}`,
f.postingID, f.worker, f.starts.Format(time.RFC3339), f.ends.Format(time.RFC3339))
}
/* ── The cycle ──────────────────────────────────────────────────────────── */
func TestAssignWorkerDescribesTheWriteInWordsAPersonCanCheck(t *testing.T) {
h := testutil.New(t)
f := seedAssignable(t, h, "assign-describe")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_a"}, "assign_worker", json.RawMessage(assignArgs(f)))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
if n := assignmentCount(t, h, f.orgID); n != 0 {
t.Fatalf("%d assignments were created before anyone approved anything", n)
}
c := res.Confirmation
// The names, not the ids. A person asked to approve a pair of uuids is a
// person clicking yes without reading, which is the failure mode the whole
// renderer exists to avoid.
body, _ := json.Marshal(c)
for _, want := range []string{"Maya Chen", "Bar Supervisor"} {
if !strings.Contains(string(body), want) {
t.Errorf("the confirmation does not mention %q: %s", want, body)
}
}
if strings.Contains(c.Title, f.postingID) || strings.Contains(c.Summary, f.postingID) {
t.Error("the confirmation shows a raw id where it should show a name")
}
if !strings.Contains(string(body), "12 Sep 2030") {
t.Errorf("the confirmation does not say when the work is: %s", body)
}
}
func TestAssignWorkerWritesOnlyAfterApproval(t *testing.T) {
h := testutil.New(t)
f := seedAssignable(t, h, "assign-approve")
reg := liveRegistry(t, h)
args := assignArgs(f)
tc := tools.Context{Principal: f.admin, RunID: "run_b"}
c := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)).Confirmation
if c == nil {
t.Fatal("no confirmation was raised")
}
if n := assignmentCount(t, h, f.orgID); n != 0 {
t.Fatalf("%d assignments before approval", n)
}
tc.Confirmation = c.Token
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
if res.Error != nil {
t.Fatalf("an approved write should have run: %+v", res.Error)
}
if n := assignmentCount(t, h, f.orgID); n != 1 {
t.Fatalf("%d assignments after approval, want 1", n)
}
// And the row says an agent did it, not a person. `source` is what an
// operator reads when they ask why somebody is on a roster.
var source, name string
if err := h.Pool.QueryRow(context.Background(),
`SELECT source, worker_name FROM assignments WHERE org_id = $1::uuid`, f.orgID,
).Scan(&source, &name); err != nil {
t.Fatalf("read back: %v", err)
}
if source != "agent" {
t.Errorf("assignment source is %q; an agent-created row must say so", source)
}
if name != "Maya Chen" {
t.Errorf("worker_name is %q, want Maya Chen", name)
}
}
func TestApprovingOneAssignmentDoesNotApproveAnother(t *testing.T) {
// The end-to-end version of the binding test, against real rows: a person
// approves Maya on the bar shift, and the token is then presented for a
// different period. Nothing may be written.
h := testutil.New(t)
f := seedAssignable(t, h, "assign-swap")
reg := liveRegistry(t, h)
tc := tools.Context{Principal: f.admin, RunID: "run_c"}
c := reg.Dispatch(context.Background(), tc, "assign_worker",
json.RawMessage(assignArgs(f))).Confirmation
if c == nil {
t.Fatal("no confirmation was raised")
}
other := f
other.starts = f.starts.AddDate(0, 0, 1)
other.ends = f.ends.AddDate(0, 0, 1)
tc.Confirmation = c.Token
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(assignArgs(other)))
if n := assignmentCount(t, h, f.orgID); n != 0 {
t.Fatalf("%d assignments written on a substituted approval", n)
}
// The substituted call is described rather than merely refused, so the
// person is asked about the shift that is actually being proposed.
if res.Confirmation == nil {
t.Fatal("the substituted call raised no confirmation of its own")
}
if res.Confirmation.Token == c.Token {
t.Fatal("the approval for one shift was handed back for another")
}
}
/* ── Authorization ──────────────────────────────────────────────────────── */
func TestTalentCannotAssignThemselves(t *testing.T) {
// `assignments` lists to everyone and creates for operators only. A talent
// caller may read their own roster perfectly well, so asking the policy the
// READ question here would have let them put themselves on a shift — which
// is exactly the bug authorizeOp exists to prevent.
h := testutil.New(t)
f := seedAssignable(t, h, "assign-talent")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.talent, RunID: "run_d"}, "assign_worker",
json.RawMessage(assignArgs(f)))
if res.Confirmation != nil {
t.Fatal("a talent caller was asked to approve a write they may not make")
}
if res.Error == nil || res.Error.Code != tools.CodeDenied {
t.Fatalf("want the standard denial, got %+v", res.Error)
}
if n := assignmentCount(t, h, f.orgID); n != 0 {
t.Fatalf("%d assignments created by a talent caller", n)
}
}
func TestAssignWorkerCannotReachAnotherTenantsRole(t *testing.T) {
// The posting is resolved behind the caller's own read predicate, so a role
// id from another organization is not merely refused — it is refused
// identically to one that does not exist. Otherwise assign_worker becomes a
// way to ask whether a given uuid is real.
h := testutil.New(t)
mine := seedAssignable(t, h, "assign-mine")
theirs := seedAssignable(t, h, "assign-theirs")
reg := liveRegistry(t, h)
crossed := mine
crossed.postingID = theirs.postingID
real := reg.Dispatch(context.Background(),
tools.Context{Principal: mine.admin, RunID: "run_e"}, "assign_worker",
json.RawMessage(assignArgs(crossed)))
invented := mine
invented.postingID = "00000000-0000-0000-0000-0000000000ff"
fake := reg.Dispatch(context.Background(),
tools.Context{Principal: mine.admin, RunID: "run_e"}, "assign_worker",
json.RawMessage(assignArgs(invented)))
if real.Error == nil {
t.Fatal("a role from another tenant was accepted")
}
if fake.Error == nil {
t.Fatal("an invented role id was accepted")
}
if real.Error.Code != fake.Error.Code || real.Error.Message != fake.Error.Message {
t.Errorf("a real-but-forbidden role is distinguishable from an imaginary one:\n"+
" other tenant: %s — %s\n invented: %s — %s",
real.Error.Code, real.Error.Message, fake.Error.Code, fake.Error.Message)
}
if n := assignmentCount(t, h, theirs.orgID); n != 0 {
t.Fatal("a write crossed a tenant boundary")
}
}
/* ── What the description warns about ───────────────────────────────────── */
func TestADoubleBookingIsWarnedAboutAndThenRefused(t *testing.T) {
// The renderer warns; the handler refuses. Both, because the gap between
// asking and answering is unbounded, and a clash that appears inside it is
// one nobody was shown.
h := testutil.New(t)
f := seedAssignable(t, h, "assign-clash")
reg := liveRegistry(t, h)
args := assignArgs(f)
tc := tools.Context{Principal: f.admin, RunID: "run_f"}
// First assignment, approved and written.
c := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)).Confirmation
tc.Confirmation = c.Token
if res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)); res.Error != nil {
t.Fatalf("first assignment failed: %+v", res.Error)
}
// Second, over the same window. Now the renderer has something to say.
tc.Confirmation = ""
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatal("no confirmation was raised for the clashing assignment")
}
if len(res.Confirmation.Warnings) == 0 {
t.Fatal("a double-booking was described without a warning")
}
found := false
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, "double-book") {
found = true
}
}
if !found {
t.Errorf("the warnings do not mention the clash: %v", res.Confirmation.Warnings)
}
// And approving it anyway is still refused, because the clash is real now
// rather than merely predicted.
tc.Confirmation = res.Confirmation.Token
if out := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)); out.Error == nil {
t.Fatal("an approved double-booking was written")
}
if n := assignmentCount(t, h, f.orgID); n != 1 {
t.Fatalf("%d assignments, want 1 — the clash was written anyway", n)
}
}
func TestGoingOverHeadcountIsWarnedAbout(t *testing.T) {
h := testutil.New(t)
f := seedAssignable(t, h, "assign-headcount")
reg := liveRegistry(t, h)
ctx := context.Background()
// The posting asks for 2. Fill both with other people, so the third is over
// headcount without also being a clash for our worker.
for i, email := range []string{"a@example.test", "b@example.test"} {
if _, err := h.Pool.Exec(ctx, `
INSERT INTO assignments (org_id, job_posting_id, worker_email, worker_name, starts_at, ends_at)
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6)`,
f.orgID, f.postingID, email, fmt.Sprintf("Worker %d", i), f.starts, f.ends); err != nil {
t.Fatalf("seed assignment: %v", err)
}
}
res := reg.Dispatch(ctx, tools.Context{Principal: f.admin, RunID: "run_g"},
"assign_worker", json.RawMessage(assignArgs(f)))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
found := false
for _, w := range res.Confirmation.Warnings {
if strings.Contains(w, "headcount") {
found = true
}
}
if !found {
t.Errorf("going over headcount was not warned about: %v", res.Confirmation.Warnings)
}
}
/* ── The lookups ────────────────────────────────────────────────────────── */
func TestOpenPositionsReturnsIdsAndCountsWhatIsLeftToFill(t *testing.T) {
// §4: a tool that requires the model to guess an id is a design bug. This
// is the lookup that makes assign_worker usable without guessing.
h := testutil.New(t)
f := seedAssignable(t, h, "open-positions")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_h"}, "open_positions", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("open_positions failed: %+v", res.Error)
}
body, _ := json.Marshal(res.Data)
if !strings.Contains(string(body), f.postingID) {
t.Errorf("open_positions did not return the role's id: %s", body)
}
if !strings.Contains(string(body), "stillToFill") {
t.Errorf("open_positions does not say how many are still needed: %s", body)
}
}
func TestAvailableWorkersExcludesSomebodyAlreadyBooked(t *testing.T) {
h := testutil.New(t)
f := seedAssignable(t, h, "available")
reg := liveRegistry(t, h)
ctx := context.Background()
window := fmt.Sprintf(`{"starts_at":%q,"ends_at":%q}`,
f.starts.Format(time.RFC3339), f.ends.Format(time.RFC3339))
tc := tools.Context{Principal: f.admin, RunID: "run_i"}
res := reg.Dispatch(ctx, tc, "available_workers", json.RawMessage(window))
if res.Error != nil {
t.Fatalf("available_workers failed: %+v", res.Error)
}
if body, _ := json.Marshal(res.Data); !strings.Contains(string(body), f.worker) {
t.Fatalf("a free worker was not listed: %s", body)
}
if _, err := h.Pool.Exec(ctx, `
INSERT INTO assignments (org_id, job_posting_id, worker_email, worker_name, starts_at, ends_at)
VALUES ($1::uuid, $2::uuid, $3, 'Maya Chen', $4, $5)`,
f.orgID, f.postingID, f.worker, f.starts, f.ends); err != nil {
t.Fatalf("seed clash: %v", err)
}
res = reg.Dispatch(ctx, tc, "available_workers", json.RawMessage(window))
if body, _ := json.Marshal(res.Data); strings.Contains(string(body), f.worker) {
t.Errorf("a booked worker was still reported as available: %s", body)
}
}
func TestAvailableWorkersSaysWhatAvailableMeans(t *testing.T) {
// The word carries more meaning to a reader than the query can support. A
// model handed a tool called `available_workers` will otherwise report its
// output as availability in the ordinary sense, and a manager will act on
// it as though somebody had been asked.
h := testutil.New(t)
f := seedAssignable(t, h, "available-meaning")
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: f.admin, RunID: "run_j"}, "available_workers",
json.RawMessage(fmt.Sprintf(`{"starts_at":%q}`, f.starts.Format(time.RFC3339))))
if res.Error != nil {
t.Fatalf("available_workers failed: %+v", res.Error)
}
body, _ := json.Marshal(res.Data)
if !strings.Contains(string(body), "not mean") && !strings.Contains(string(body), "Not a statement") {
t.Errorf("the result does not qualify what availability means: %s", body)
}
}

View File

@@ -0,0 +1,384 @@
package tools
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// The confirmation gate: I4, and the one place a write is allowed to happen.
//
// The invariant is short — "any tool that writes, sends, deletes, charges or
// notifies cannot execute without a resolved confirmation token" — but the
// naive reading of it is not safe, and the difference is the whole of this
// file.
//
// The naive reading is a boolean: ask, get a yes, run. That version has a hole
// wide enough to drive a payroll through. A person approves "assign Maya Chen
// to Friday's bar shift"; the model, on the next turn, calls the same tool with
// a different worker and the same yes still applies. Nothing in a boolean
// distinguishes those two calls, so the approval a person gave to one becomes
// an approval they never gave to the other.
//
// So a confirmation here is a *binding*, not a flag. A token is issued against
// a fingerprint of exactly what was described to the person:
//
// tool name + canonical inputs + principal + tenant
//
// and it validates only against a call carrying that same fingerprint. Change
// the worker, change the shift, change the caller, cross a tenant — each of
// those produces a different fingerprint and the token is refused. It is also
// single-use, so one approval buys exactly one write.
//
// WHY THE RUN IS RECORDED BUT NOT MATCHED
//
// The fingerprint originally included the run id, which is the tighter thing to
// do and was wrong. A confirmation exists precisely so that a run can END and a
// person can be asked; the run that resumes afterwards is a new run with a new
// id, so matching on it made every token unredeemable — the mechanism refused
// exactly the case it was built for.
//
// The run id is still stored, because "which conversation proposed this write"
// is worth being able to answer. It is not part of the match, and what covers
// the gap is the rest of the binding: the arguments are identical, so a token
// replayed in a later run authorises the very write it described; the TTL bounds
// how stale the surrounding facts can be; single-use bounds it to one; and the
// handler re-checks the world before writing. What a run-scoped match would have
// added on top of that is protection against a surface that hands back a token
// the user never clicked — which is a bug in the surface, not a hole a token
// format can close.
//
// The second half is Confirmer. A person cannot approve what they cannot read,
// and `{"job_posting_id":"3f2b...","worker_profile_id":"91ac..."}` is not
// something anyone can approve honestly. Every write tool must render a plain
// language description of what will happen — and must do it *behind the same
// authorization as the write itself*, because a renderer that resolves a name
// the caller may not see has leaked that name in the course of asking whether
// to proceed.
//
// Timing: the description is rendered from the same inputs that are
// fingerprinted, at the moment of asking. It is a description of the call, not
// a promise about the world — the underlying rows can still change between
// asking and executing. Where that matters, the handler re-checks; see
// assignments.go for the one case where it does.
/* ── What a person is asked to approve ──────────────────────────────────── */
// Confirmation is a pending write, described for a human.
type Confirmation struct {
// Token is what resolves this confirmation. Opaque, single-use, and bound
// to the exact call it was issued for.
Token string `json:"token"`
Tool string `json:"tool"`
// Title is one line, plain language, no ids. "Assign Maya Chen to Bar
// Supervisor".
Title string `json:"title"`
// Summary says what will happen if this is approved, in a sentence a
// person can hold against their own intent.
Summary string `json:"summary"`
// Details are the specifics, resolved to names rather than ids. Rendered as
// a list beside the summary.
Details []Detail `json:"details,omitempty"`
// Warnings are things the person should know before saying yes — a clash,
// an overtime threshold, a role already filled. Present precisely because
// the model is not trusted to surface them.
Warnings []string `json:"warnings,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
// Detail is one labelled fact in a confirmation.
type Detail struct {
Label string `json:"label"`
Value string `json:"value"`
}
// Confirmer renders what a write will do, before it does it.
//
// Returns either a description or a refusal, never both. The refusal is the
// same opaque Denied() every handler returns: a renderer that explained why it
// could not describe something would answer, at confirmation time, the question
// the denial exists to leave unanswered.
//
// A renderer must not write anything. It runs before any approval exists.
type Confirmer func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result)
/* ── The binding ────────────────────────────────────────────────────────── */
// binding is the fingerprint a token is issued against.
type binding struct {
Tool string
InputsHash string
UserID string
OrgID string
RunID string
// Inputs are the arguments themselves, carried alongside their hash so a
// store can record them. The hash is what a re-derived call is MATCHED
// against; these are what a redeemed token REPLAYS. Both paths exist —
// see Store.Redeem for why the second one had to.
Inputs json.RawMessage
// AgentID is whose spec proposed this. Not used to authorise; recorded
// because a redeemed call runs outside any agent's resolved tool list, and
// "which agent offered this" is the question an audit of that asks.
AgentID string
}
// bind fingerprints a call.
//
// RunID is captured for the record rather than for the match.
//
// Inputs are canonicalised before hashing, so a model that reorders keys or
// re-spaces its JSON between the asking turn and the executing turn does not
// invalidate a perfectly good approval. Anything that canonicalisation cannot
// parse is hashed verbatim — a malformed body is not a reason to widen what a
// token matches.
func bind(tc Context, tool string, inputs json.RawMessage) binding {
canonical := canonicalJSON(inputs)
sum := sha256.Sum256(canonical)
return binding{
Tool: tool,
InputsHash: hex.EncodeToString(sum[:]),
UserID: tc.Principal.UserID,
OrgID: tc.Principal.OrgID,
RunID: tc.RunID,
Inputs: json.RawMessage(canonical),
AgentID: tc.AgentID,
}
}
// matches reports whether two bindings are the same call.
//
// RunID is deliberately absent — see the note at the top of this file. Every
// other field is compared, and the hash is compared in constant time: the token
// itself is unguessable so this is not the load-bearing secret, but a
// comparison that leaks where two inputs first differ is a comparison worth not
// writing in the first place.
func (b binding) matches(other binding) bool {
return b.Tool == other.Tool &&
b.UserID == other.UserID &&
b.OrgID == other.OrgID &&
subtle.ConstantTimeCompare([]byte(b.InputsHash), []byte(other.InputsHash)) == 1
}
// canonicalJSON re-encodes a JSON document with object keys sorted.
//
// Two calls that mean the same thing must fingerprint the same, or a person
// would be asked to approve the identical write twice because the model
// happened to emit its arguments in a different order.
func canonicalJSON(raw json.RawMessage) []byte {
if len(raw) == 0 {
return []byte("null")
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return raw
}
var b strings.Builder
writeCanonical(&b, v)
return []byte(b.String())
}
func writeCanonical(b *strings.Builder, v any) {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
b.WriteByte('{')
for i, k := range keys {
if i > 0 {
b.WriteByte(',')
}
encoded, _ := json.Marshal(k)
b.Write(encoded)
b.WriteByte(':')
writeCanonical(b, t[k])
}
b.WriteByte('}')
case []any:
b.WriteByte('[')
for i, item := range t {
if i > 0 {
b.WriteByte(',')
}
writeCanonical(b, item)
}
b.WriteByte(']')
default:
encoded, _ := json.Marshal(t)
b.Write(encoded)
}
}
/* ── Where pending confirmations live ───────────────────────────────────── */
// ConfirmationTTL is how long an unanswered confirmation stays answerable.
//
// Bounded because an approval is a judgement about a moment. A yes clicked on
// a two-day-old "assign Maya to Friday's shift" is a yes to a question whose
// answer has probably changed, and there is no way for the person clicking to
// know that. Expiring forces the question to be asked again against current
// facts.
const ConfirmationTTL = 30 * time.Minute
// Store holds confirmations between being asked and being answered.
//
// Two operations, and the second is the interesting one: Resolve must be
// atomic. Two concurrent calls carrying the same token must not both succeed,
// or a single approval buys two writes — which is the same hole the binding
// closes, arriving by a different door.
type Store interface {
// Issue records a pending confirmation and returns its token.
Issue(ctx context.Context, b binding, c *Confirmation) error
// Resolve consumes a token, reporting whether it authorises this exact
// call. A token that does not exist, has expired, has already been used or
// was issued for a different call all return false — indistinguishably,
// because telling them apart is an oracle over other people's pending
// approvals, and because the caller's response to all four is the same:
// describe the call and ask again.
//
// Only a matching token is consumed. A mismatch must leave the token
// spendable by the call it was issued for.
Resolve(ctx context.Context, token string, b binding) bool
// Redeem consumes a token and returns the call it authorised.
//
// The difference from Resolve is which direction the arguments travel, and
// it is the whole reason this exists. Resolve is handed a call and asked
// "was this approved?" — which requires the model to have produced the same
// call again. Redeem is handed only the token and asked "what was
// approved?", so honouring an approval does not depend on a model
// reproducing itself.
//
// The caller is still checked: a token belongs to one principal in one
// tenant, and Redeem refuses one presented by anybody else. What it does
// NOT check is the arguments, because it is the source of them.
Redeem(ctx context.Context, token string, p Principal) (Approved, bool)
}
// Principal identifies who is redeeming, without the whole identity.
//
// Deliberately just the two fields a token is bound to. A Store has no business
// with a caller's role or email — it is answering "is this the person who was
// asked?", not "may this person do things?", and the second question was
// already settled when the confirmation was raised.
type Principal struct {
UserID string
OrgID string
}
// Approved is a call a person authorised.
type Approved struct {
Tool string
Inputs json.RawMessage
AgentID string
}
// newToken returns an unguessable confirmation token.
func newToken() (string, error) {
var b [24]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("tools: no randomness for a confirmation token: %w", err)
}
return "cnf_" + hex.EncodeToString(b[:]), nil
}
/* ── In-memory store ────────────────────────────────────────────────────── */
// MemoryStore keeps confirmations in this process.
//
// Correct for a single instance and for tests. It is deliberately NOT the
// default in wiring: behind more than one replica, the approval would land on
// whichever instance the callback happened to reach, and roughly half of all
// approvals would be refused for no reason a user could act on. See
// PostgresStore.
type MemoryStore struct {
mu sync.Mutex
pending map[string]pendingRecord
now func() time.Time
}
type pendingRecord struct {
binding binding
inputs json.RawMessage
agentID string
expiresAt time.Time
}
// NewMemoryStore builds an empty store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{pending: map[string]pendingRecord{}, now: time.Now}
}
// Issue records a pending confirmation.
func (s *MemoryStore) Issue(_ context.Context, b binding, c *Confirmation) error {
s.mu.Lock()
defer s.mu.Unlock()
s.pending[c.Token] = pendingRecord{
binding: b, inputs: b.Inputs, agentID: b.AgentID, expiresAt: c.ExpiresAt,
}
return nil
}
// Redeem consumes a token and returns what it authorised.
func (s *MemoryStore) Redeem(_ context.Context, token string, p Principal) (Approved, bool) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.pending[token]
if !ok {
return Approved{}, false
}
if s.now().After(rec.expiresAt) {
return Approved{}, false
}
// The caller has to be the one who was asked. Same tenant, same person.
if rec.binding.UserID != p.UserID || rec.binding.OrgID != p.OrgID {
return Approved{}, false
}
delete(s.pending, token)
return Approved{Tool: rec.binding.Tool, Inputs: rec.inputs, AgentID: rec.agentID}, true
}
// Resolve consumes a token if it authorises this call.
//
// Lookup, check and delete all happen under one lock, which is what makes
// single-use mean single-use rather than "usually single-use": two goroutines
// arriving together cannot both find the token present.
//
// A token that does not match this call is left alone rather than spent. It was
// issued for some other call, and that call may still be about to arrive — in
// the same turn, even. Spending it here would refuse the write the person
// actually approved.
func (s *MemoryStore) Resolve(_ context.Context, token string, b binding) bool {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.pending[token]
if !ok {
return false
}
if s.now().After(rec.expiresAt) || !rec.binding.matches(b) {
return false
}
delete(s.pending, token)
return true
}

View File

@@ -0,0 +1,178 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// PostgresStore keeps pending confirmations in agent_confirmations.
//
// The store MemoryStore should have been. A confirmation is asked for in one
// request and answered in another, and there is no reason those two requests
// reach the same process — behind two replicas, an in-process store refuses
// roughly half of all approvals, and refuses them in a way the person clicking
// cannot act on and cannot even see the cause of.
type PostgresStore struct {
db repo.Querier
}
// NewPostgresStore builds a store over a pool or transaction.
func NewPostgresStore(db repo.Querier) *PostgresStore { return &PostgresStore{db: db} }
var _ Store = (*PostgresStore)(nil)
// Issue records a pending confirmation.
//
// Fails loudly. The registry treats an Issue error as a reason to refuse the
// tool outright, because showing somebody a question whose answer will be
// discarded is worse than saying the tool is unavailable.
func (s *PostgresStore) Issue(ctx context.Context, b binding, c *Confirmation) error {
if b.OrgID == "" {
// I5. There is no tenant-less confirmation, and writing one would put a
// row in the table that no caller could ever legitimately resolve.
return errors.New("tools: a confirmation needs an organization")
}
if c == nil || c.Token == "" {
return errors.New("tools: a confirmation needs a token")
}
payload, err := json.Marshal(c)
if err != nil {
return fmt.Errorf("tools: encoding a confirmation: %w", err)
}
inputs := b.Inputs
if len(inputs) == 0 {
inputs = json.RawMessage("{}")
}
_, err = s.db.Exec(ctx, `
INSERT INTO agent_confirmations
(token, org_id, user_id, run_id, tool, inputs_hash, inputs, agent_id, payload, expires_at)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7::jsonb, $8, $9::jsonb, $10)`,
c.Token, b.OrgID, nullableUUID(b.UserID), b.RunID, b.Tool, b.InputsHash,
string(inputs), b.AgentID, payload, c.ExpiresAt)
if err != nil {
return fmt.Errorf("tools: recording a confirmation: %w", err)
}
return nil
}
// Resolve consumes a token if it authorises this exact call.
//
// The claim and the check are ONE statement, and they have to be. Reading the
// row and then updating it would be the same logic with a race in the middle,
// and the race is a duplicated write — precisely the failure this mechanism
// exists to prevent. `consumed_at IS NULL` in the WHERE clause combined with
// RETURNING means exactly one concurrent caller can take a token.
//
// The binding is in the same WHERE clause rather than compared afterwards, so a
// token presented against a DIFFERENT call is not consumed. That matters when a
// turn contains two write calls: spending the token on whichever was dispatched
// first would refuse the one the person actually approved.
//
// run_id is stored but not matched — a resumed run has a new id by definition.
// See the note in confirm.go.
//
// The hash comparison is SQL's rather than constant-time. The token is the
// secret and it is unguessable; the hash is only reached by a caller who
// already holds the token, so there is no oracle to protect here.
func (s *PostgresStore) Resolve(ctx context.Context, token string, b binding) bool {
if token == "" {
return false
}
var claimed string
err := s.db.QueryRow(ctx, `
UPDATE agent_confirmations
SET consumed_at = now()
WHERE token = $1
AND consumed_at IS NULL
AND expires_at > now()
AND tool = $2
AND inputs_hash = $3
AND org_id = $4::uuid
AND user_id IS NOT DISTINCT FROM $5::uuid
RETURNING token`,
token, b.Tool, b.InputsHash, b.OrgID, nullableUUID(b.UserID),
).Scan(&claimed)
// No rows is the ordinary case: no such token, already spent, expired, or
// issued for a different call. Any other error is a database problem, and
// the safe reading of "I could not verify this approval" is that it is not
// approved. All of them refuse identically — a caller able to distinguish
// "spent" from "never existed" could probe other people's approvals.
return err == nil && claimed == token
}
// Sweep deletes confirmations that can no longer be answered.
//
// Housekeeping, not a security control: Resolve refuses an expired token
// whether or not this has run. Returns how many rows it removed so a scheduled
// caller can log something true.
func (s *PostgresStore) Sweep(ctx context.Context, olderThan time.Duration) (int64, error) {
tag, err := s.db.Exec(ctx, `
DELETE FROM agent_confirmations
WHERE expires_at < now() - $1::interval`,
olderThan.String())
if err != nil {
return 0, fmt.Errorf("tools: sweeping confirmations: %w", err)
}
return tag.RowsAffected(), nil
}
// nullableUUID renders an empty principal id as SQL NULL.
//
// user_id is nullable and references users; an empty string would fail the cast
// rather than storing "unknown", which is a real state — a run started by a
// service principal has no user row behind it.
func nullableUUID(s string) any {
if s == "" {
return nil
}
return s
}
// Redeem consumes a token and returns the call it authorised.
//
// One statement again, and for the same reason Resolve is: the claim and the
// read have to be atomic or two concurrent redemptions both succeed. The
// difference is what is checked — the caller and the expiry, but NOT the
// arguments, because this is where the arguments come from.
//
// A row written before migration 000009 has a NULL `inputs`, and those cannot
// be replayed. They are refused rather than replayed as `{}`: an empty argument
// set is a different call from the one a person approved, and running it would
// be worse than declining to.
func (s *PostgresStore) Redeem(ctx context.Context, token string, p Principal) (Approved, bool) {
if token == "" || p.OrgID == "" {
return Approved{}, false
}
var (
tool string
inputs []byte
agentID string
)
err := s.db.QueryRow(ctx, `
UPDATE agent_confirmations
SET consumed_at = now()
WHERE token = $1
AND consumed_at IS NULL
AND expires_at > now()
AND org_id = $2::uuid
AND user_id IS NOT DISTINCT FROM $3::uuid
AND inputs IS NOT NULL
RETURNING tool, inputs, agent_id`,
token, p.OrgID, nullableUUID(p.UserID),
).Scan(&tool, &inputs, &agentID)
if err != nil {
return Approved{}, false
}
return Approved{Tool: tool, Inputs: json.RawMessage(inputs), AgentID: agentID}, true
}

View File

@@ -0,0 +1,530 @@
package tools_test
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// These tests are about one question: what, exactly, does a person's approval
// authorise?
//
// The answer I4 is usually given is "the write" — and if a confirmation were a
// boolean, that answer would be wrong in a way nobody notices until it matters.
// A yes given to "assign Maya to Friday" would equally authorise "assign Dan to
// Saturday", because a boolean cannot tell them apart. Everything below exists
// to prove the token can.
/* ── A harness that records what actually ran ───────────────────────────── */
// spyWrite is a write tool that counts its own executions.
//
// The assertion that matters in most of these tests is not what Dispatch
// returned but whether the handler ran at all. A refusal that still wrote is a
// bug that a result-shaped assertion would sail straight past.
type spyWrite struct {
runs atomic.Int64
asked atomic.Int64
denied bool
panics bool
expiry time.Time
}
func (s *spyWrite) tool() tools.Tool {
return tools.Tool{
Name: "assign_worker",
Description: "Assign somebody to something.",
InputSchema: map[string]any{"type": "object"},
Effect: tools.EffectWrite,
Confirm: func(_ context.Context, tc tools.Context, in json.RawMessage) (*tools.Confirmation, *tools.Result) {
s.asked.Add(1)
if s.panics {
panic("a renderer that blew up")
}
if s.denied {
d := tools.Denied()
return nil, &d
}
return &tools.Confirmation{
Title: "Assign somebody",
Summary: "Somebody will be assigned to something.",
Details: []tools.Detail{{Label: "Arguments", Value: string(in)}},
ExpiresAt: s.expiry,
}, nil
},
Handler: func(context.Context, tools.Context, json.RawMessage) tools.Result {
s.runs.Add(1)
return tools.OK(map[string]any{"written": true})
},
}
}
func caller(user, org string) tools.Context {
return tools.Context{
Principal: authctx.Identity{UserID: user, OrgID: org, Role: "admin"},
RunID: "run_one",
}
}
// ask dispatches a write with no token and returns the confirmation it raised.
func ask(t *testing.T, reg *tools.Registry, tc tools.Context, args string) *tools.Confirmation {
t.Helper()
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
if res.Confirmation == nil {
t.Fatalf("expected a confirmation, got %+v", res)
}
return res.Confirmation
}
// notApproved asserts that a call was not authorised: nothing was written, and
// the caller was asked afresh rather than let through.
//
// "Asked afresh" is the shape of every refusal here, and it is deliberate. A
// token that does not authorise THIS call — wrong arguments, wrong caller,
// expired, already spent, invented — all mean the same thing, which is that
// nobody has approved what is about to happen. The honest response to that is
// to describe it and ask, not to hand the model an error it cannot act on.
func notApproved(t *testing.T, res tools.Result, spy *spyWrite, staleToken string) {
t.Helper()
if spy.runs.Load() != 0 {
t.Fatalf("the write ran %d times without an approval for it", spy.runs.Load())
}
if res.Data != nil {
t.Fatal("an unapproved write produced a result")
}
if res.Confirmation == nil {
if res.Error == nil {
t.Fatal("an unapproved write was neither refused nor re-described")
}
return
}
if staleToken != "" && res.Confirmation.Token == staleToken {
t.Fatal("the stale token was handed straight back as if it were a fresh approval")
}
}
/* ── The gate ───────────────────────────────────────────────────────────── */
func TestAWriteIsDescribedBeforeItIsDone(t *testing.T) {
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
c := ask(t, reg, caller("u1", "org1"), `{"worker":"maya"}`)
if spy.runs.Load() != 0 {
t.Fatal("the handler ran before anybody approved anything")
}
if c.Token == "" {
t.Error("a confirmation with no token can never be answered")
}
if c.Tool != "assign_worker" {
t.Errorf("confirmation names tool %q, want assign_worker", c.Tool)
}
if c.Title == "" || c.Summary == "" {
t.Error("a person cannot approve a confirmation with nothing written on it")
}
if c.ExpiresAt.IsZero() {
t.Error("a confirmation that never expires is a standing authorisation")
}
}
func TestAnApprovalAuthorisesOnlyTheCallItDescribed(t *testing.T) {
// The whole reason a confirmation is a binding rather than a flag.
//
// A person is shown "assign maya" and approves it. The model then calls the
// same tool for a different worker, carrying the same token. If that
// succeeded, the approval a person gave to one write would have silently
// become approval of another — which is not a permissions bug the user
// could ever detect, because the dialog they saw was accurate.
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
approved := ask(t, reg, tc, `{"worker":"maya","shift":"friday"}`)
tc.Confirmation = approved.Token
res := reg.Dispatch(context.Background(), tc, "assign_worker",
json.RawMessage(`{"worker":"dan","shift":"friday"}`))
// Not merely refused: the substituted call is DESCRIBED, so the person is
// asked about the write that is actually being proposed.
notApproved(t, res, spy, approved.Token)
if res.Confirmation == nil {
t.Fatal("the substituted call should have raised its own confirmation")
}
}
func TestAnApprovalRunsTheCallItDescribed(t *testing.T) {
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
args := `{"worker":"maya","shift":"friday"}`
approved := ask(t, reg, tc, args)
tc.Confirmation = approved.Token
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
if res.Error != nil {
t.Fatalf("an approved write should run: %+v", res.Error)
}
if spy.runs.Load() != 1 {
t.Fatalf("handler ran %d times, want exactly 1", spy.runs.Load())
}
}
func TestReorderedArgumentsAreStillTheSameCall(t *testing.T) {
// The other direction, and the reason inputs are canonicalised rather than
// hashed verbatim. A model that emits its arguments in a different order on
// the resumed turn has not changed what it is asking for, and refusing it
// would make approvals fail at random.
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
approved := ask(t, reg, tc, `{"worker":"maya","shift":"friday"}`)
tc.Confirmation = approved.Token
res := reg.Dispatch(context.Background(), tc, "assign_worker",
json.RawMessage(`{ "shift" : "friday", "worker" : "maya" }`))
if res.Error != nil {
t.Fatalf("reordered and re-spaced arguments are the same call: %+v", res.Error)
}
if spy.runs.Load() != 1 {
t.Fatal("the same call, written differently, should have run")
}
}
func TestAnApprovalIsSpentOnce(t *testing.T) {
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
args := `{"worker":"maya"}`
approved := ask(t, reg, tc, args)
tc.Confirmation = approved.Token
reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
if spy.runs.Load() != 1 {
t.Fatalf("one approval bought %d writes", spy.runs.Load())
}
if res.Data != nil {
t.Fatal("a spent token authorised a second write")
}
if res.Confirmation == nil {
t.Fatal("the second call should have raised its own confirmation")
}
if res.Confirmation.Token == approved.Token {
t.Fatal("a spent token was reissued")
}
}
func TestConcurrentAttemptsSpendAnApprovalOnce(t *testing.T) {
// Single-use has to survive two goroutines arriving at the same instant, or
// it is only single-use in the happy path — and the unhappy path is a
// duplicated assignment nobody ordered.
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
args := `{"worker":"maya"}`
tc.Confirmation = ask(t, reg, tc, args).Token
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args))
}()
}
wg.Wait()
if got := spy.runs.Load(); got != 1 {
t.Fatalf("16 concurrent attempts on one token produced %d writes, want 1", got)
}
}
func TestAnApprovalDoesNotCrossCallers(t *testing.T) {
// A token is not a bearer credential for the tool. It authorises one
// person's decision, and a second caller holding it — in the same tenant,
// same run, same arguments — is not that person.
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
args := `{"worker":"maya"}`
approved := ask(t, reg, caller("u1", "org1"), args)
other := caller("u2", "org1")
other.Confirmation = approved.Token
notApproved(t, reg.Dispatch(context.Background(), other, "assign_worker", json.RawMessage(args)),
spy, approved.Token)
}
func TestAnApprovalDoesNotCrossTenants(t *testing.T) {
// I5, arriving by way of I4. The same user id in a different organization
// is a different principal, and a confirmation issued in one tenant must
// not act in another.
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
args := `{"worker":"maya"}`
approved := ask(t, reg, caller("u1", "org1"), args)
elsewhere := caller("u1", "org2")
elsewhere.Confirmation = approved.Token
notApproved(t, reg.Dispatch(context.Background(), elsewhere, "assign_worker", json.RawMessage(args)),
spy, approved.Token)
}
func TestAnApprovalSurvivesTheRunEnding(t *testing.T) {
// The case the whole mechanism exists for, and the one an earlier version
// of this code broke.
//
// A confirmation ends the run — that is the point: the model stops, a person
// is asked, and the answer arrives later. The run that resumes is a NEW run
// with a new id, so a token scoped to the run that raised it could never be
// redeemed by the run that resumes. Binding on the run read as the tighter
// choice and was in fact the choice that refused every legitimate approval.
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
args := `{"worker":"maya"}`
approved := ask(t, reg, caller("u1", "org1"), args)
resumed := caller("u1", "org1")
resumed.RunID = "run_two" // a different run, as a resumed one always is
resumed.Confirmation = approved.Token
if res := reg.Dispatch(context.Background(), resumed, "assign_worker", json.RawMessage(args)); res.Error != nil {
t.Fatalf("an approval must survive the run that raised it: %+v", res.Error)
}
if spy.runs.Load() != 1 {
t.Fatal("the approved write did not run on resumption")
}
}
func TestAnExpiredApprovalIsRefused(t *testing.T) {
// An approval is a judgement about a moment. Honouring a two-day-old yes
// answers a question whose facts have moved on, and the person who clicked
// had no way to know that.
spy := &spyWrite{expiry: time.Now().Add(-time.Minute)}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
args := `{"worker":"maya"}`
stale := ask(t, reg, tc, args).Token
tc.Confirmation = stale
notApproved(t, reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(args)), spy, stale)
}
func TestAnInventedTokenAuthorisesNothing(t *testing.T) {
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
tc.Confirmation = "cnf_this-looks-about-right"
res := reg.Dispatch(context.Background(), tc, "assign_worker", json.RawMessage(`{}`))
notApproved(t, res, spy, tc.Confirmation)
if res.Confirmation == nil {
t.Fatal("an invented token should leave the call unapproved and described afresh")
}
}
/* ── The renderer ───────────────────────────────────────────────────────── */
func TestARefusedRendererIssuesNothingAndSaysNothing(t *testing.T) {
// A renderer authorizes on the same terms as the write. When it refuses,
// the refusal must be the ordinary opaque one — a distinguishable "I cannot
// describe that" would answer, at confirmation time, exactly the question
// the denial exists to leave unanswered.
spy := &spyWrite{denied: true}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
res := reg.Dispatch(context.Background(), caller("u1", "org1"), "assign_worker", json.RawMessage(`{}`))
if res.Confirmation != nil {
t.Fatal("a refused caller was still handed a token")
}
if res.Error == nil || res.Error.Code != tools.CodeDenied {
t.Fatalf("want the standard denial, got %+v", res.Error)
}
if res.Error.Message != tools.Denied().Error.Message {
t.Error("a renderer's refusal must be worded identically to every other refusal")
}
if spy.runs.Load() != 0 {
t.Fatal("a refused write ran anyway")
}
}
func TestAPanickingRendererDoesNotWrite(t *testing.T) {
// A renderer is author-written code running before any approval exists. It
// gets the same containment a handler does, and the failure direction is
// closed: no description, no token, no write.
spy := &spyWrite{panics: true}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
res := reg.Dispatch(context.Background(), caller("u1", "org1"), "assign_worker", json.RawMessage(`{}`))
if res.Error == nil {
t.Fatal("a renderer that panicked should have produced an error result")
}
if res.Confirmation != nil {
t.Fatal("a panicking renderer still issued a token")
}
if spy.runs.Load() != 0 {
t.Fatal("a write ran after its renderer panicked")
}
}
func TestReadToolsAreNotGated(t *testing.T) {
// The gate applies to effects, not to every tool. A read that had to be
// approved would teach people to approve without reading, which is how a
// confirmation dialog stops being a control.
reg := tools.NewRegistry()
var ran atomic.Int64
reg.MustRegister(tools.Tool{
Name: "activity_breakdown", Description: "Read.", Effect: tools.EffectRead,
InputSchema: map[string]any{"type": "object"},
Handler: func(context.Context, tools.Context, json.RawMessage) tools.Result {
ran.Add(1)
return tools.OK(map[string]any{"ok": true})
},
})
res := reg.Dispatch(context.Background(), caller("u1", "org1"), "activity_breakdown", json.RawMessage(`{}`))
if res.Error != nil || ran.Load() != 1 {
t.Fatalf("a read should run unasked: err=%+v ran=%d", res.Error, ran.Load())
}
if res.Confirmation != nil {
t.Error("a read raised a confirmation")
}
}
/* ── Redeeming ──────────────────────────────────────────────────────────── */
func TestRedeemingAnApprovalPerformsExactlyWhatWasDescribed(t *testing.T) {
// The path that makes a confirmation reliable rather than hopeful.
//
// Resolve asks "was THIS call approved?", which needs the caller to produce
// the same call again. Redeem asks "what WAS approved?", so honouring an
// approval does not depend on a model reproducing itself — which, against a
// real model, it does not reliably do.
var got json.RawMessage
spy := &spyWrite{}
tool := spy.tool()
inner := tool.Handler
tool.Handler = func(ctx context.Context, tc tools.Context, in json.RawMessage) tools.Result {
got = in
return inner(ctx, tc, in)
}
reg := tools.NewRegistry()
reg.MustRegister(tool)
tc := caller("u1", "org1")
args := `{"shift":"friday","worker":"maya"}`
approved := ask(t, reg, tc, args)
// Nothing about the original call is supplied — only the token.
out, ok := reg.DispatchApproved(context.Background(), caller("u1", "org1"), approved.Token)
if !ok {
t.Fatal("a valid token could not be redeemed")
}
if spy.runs.Load() != 1 {
t.Fatalf("the write ran %d times, want 1", spy.runs.Load())
}
if out.Tool != "assign_worker" {
t.Errorf("redeemed tool = %q", out.Tool)
}
// The arguments are the ones that were described, recovered from the token.
var recovered map[string]string
if err := json.Unmarshal(got, &recovered); err != nil {
t.Fatalf("the replayed arguments were not JSON: %s", got)
}
if recovered["worker"] != "maya" || recovered["shift"] != "friday" {
t.Errorf("replayed %v, want the approved call", recovered)
}
}
func TestARedeemedApprovalIsSpentOnce(t *testing.T) {
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
approved := ask(t, reg, tc, `{"worker":"maya"}`)
if _, ok := reg.DispatchApproved(context.Background(), tc, approved.Token); !ok {
t.Fatal("the first redemption failed")
}
if _, ok := reg.DispatchApproved(context.Background(), tc, approved.Token); ok {
t.Fatal("a token was redeemed twice")
}
if spy.runs.Load() != 1 {
t.Fatalf("one approval bought %d writes", spy.runs.Load())
}
}
func TestOnlyThePersonWhoWasAskedCanRedeem(t *testing.T) {
// A token is not a bearer credential. Redeem does not check the arguments —
// it is the source of them — so the caller check is the only thing standing
// between a leaked token and somebody else's write.
spy := &spyWrite{}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
approved := ask(t, reg, caller("u1", "org1"), `{"worker":"maya"}`)
for name, other := range map[string]tools.Context{
"a different person": caller("u2", "org1"),
"a different tenant": caller("u1", "org2"),
} {
if _, ok := reg.DispatchApproved(context.Background(), other, approved.Token); ok {
t.Errorf("%s redeemed an approval that was not theirs", name)
}
}
if spy.runs.Load() != 0 {
t.Fatalf("%d writes happened for callers who never approved anything", spy.runs.Load())
}
}
func TestAnExpiredApprovalCannotBeRedeemed(t *testing.T) {
spy := &spyWrite{expiry: time.Now().Add(-time.Minute)}
reg := tools.NewRegistry()
reg.MustRegister(spy.tool())
tc := caller("u1", "org1")
approved := ask(t, reg, tc, `{"worker":"maya"}`)
if _, ok := reg.DispatchApproved(context.Background(), tc, approved.Token); ok {
t.Fatal("an expired approval was redeemed")
}
if spy.runs.Load() != 0 {
t.Fatal("an expired approval produced a write")
}
}

View File

@@ -0,0 +1,36 @@
package tools_test
import (
"context"
"encoding/json"
"testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/testutil"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// TestDumpToolOutput prints what each tool actually returns against the seeded
// demo tenant. Run with -v when checking that a port produces real figures
// rather than a silent zero.
func TestDumpToolOutput(t *testing.T) {
h := testutil.New(t)
admin := authctx.Identity{
UserID: "00000000-0000-0000-0000-000000000001",
OrgID: h.OrgID, Role: "admin", Email: "admin@example.test",
}
reg := tools.NewRegistry()
for _, tool := range everyTool(h.Pool) {
reg.MustRegister(tool)
}
for _, name := range reg.Names() {
res := reg.Dispatch(context.Background(),
tools.Context{Principal: admin}, name, json.RawMessage(`{"limit":3}`))
encoded, _ := json.Marshal(res.Data)
out := string(encoded)
if len(out) > 400 {
out = out[:400] + "…"
}
t.Logf("%-24s %s", name, out)
}
}

View File

@@ -0,0 +1,502 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// The hiring tools: pipeline quality, recent hires, hire performance, roles at
// risk, and the talent pool.
//
// Each reads one resource and goes through authorize(), so the talent scopes
// differ meaningfully between them and are not restated here: applications
// scope by the caller's email, postings scope to active roles only, worker
// profiles scope by user id. That is the policy table's business, and the whole
// reason these handlers are short.
/* ── Candidate quality ──────────────────────────────────────────────────── */
// CandidatesQuality reports the applicant pipeline and how strong it is.
func CandidatesQuality(db repo.Querier) Tool {
return Tool{
Name: "candidates_quality",
Description: "Read the applicant pipeline: how many applications are at each stage, " +
"the average AI match score, how many are strong versus weak, and how many are " +
"waiting to be screened. Use for questions about candidate quality, pipeline " +
"health, and whether there is a screening backlog.",
InputSchema: periodSchema("How many stages to list. Defaults to all."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "job-applications")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("created_date", from)
q.lt("created_date", to)
}
// ai_score 0 is the absence of a score, not a score of zero — the
// same rule the product states in candidateIntelligence.js ("null
// rather than zeros ... so an unscreened candidate shows '—' instead
// of a confident-looking 0"). Counting zeros as scores reported 16
// weak candidates averaging 28 where the truth was 1 weak and 76.
var (
total, strong, weak, unscreened, scored int64
avgScore *float64
)
err := db.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE ai_score >= 80),
count(*) FILTER (WHERE ai_score > 0 AND ai_score < 50),
count(*) FILTER (WHERE status = 'applied'),
count(*) FILTER (WHERE ai_score > 0),
avg(ai_score) FILTER (WHERE ai_score > 0)
FROM job_applications
WHERE `+q.clause(), q.args...,
).Scan(&total, &strong, &weak, &unscreened, &scored, &avgScore)
if err != nil {
return Failf(CodeFailed, "the applications could not be read")
}
stages, err := groupCount(ctx, db, "job_applications", "status::text", q, in.limitOr(20))
if err != nil {
return Failf(CodeFailed, "the applications could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"applications": total,
"stages": stages,
"strong": strong,
"weak": weak,
"unscreened": unscreened,
"scored": scored,
}
// The average is over the scored ones only, so say how many that is.
if avgScore != nil {
data["averageMatchScore"] = int(*avgScore + 0.5)
data["averageMatchScoreBasis"] = scored
}
if total == 0 {
data["note"] = "No applications match that. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Recent hires ───────────────────────────────────────────────────────── */
// HiresRecent lists who was hired and for what.
func HiresRecent(db repo.Querier) Tool {
return Tool{
Name: "hires_recent",
Description: "List recent hires: who was hired, for which role, their match score " +
"and when. Use for questions about who has joined, hiring volume, and what has " +
"been filled recently.",
InputSchema: periodSchema("How many hires to list, most recent first. Defaults to 20."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "job-applications")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
// A hire is an application that reached one of the two terminal
// positive states. `assigned` counts: a worker placed on an
// assignment was hired, whatever the row was last labelled.
q.raw("status IN ('hired', 'assigned')")
if !from.IsZero() {
q.gte("created_date", from)
q.lt("created_date", to)
}
args := append(append([]any{}, q.args...), in.limitOr(20))
rows, err := db.Query(ctx, `
SELECT applicant_name, coalesce(nullif(job_title, ''), 'unspecified'),
nullif(ai_score, 0), created_date
FROM job_applications
WHERE `+q.clause()+`
ORDER BY created_date DESC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the applications could not be read")
}
defer rows.Close()
type hire struct {
Name string `json:"name"`
Role string `json:"role"`
Score *int `json:"matchScore,omitempty"`
When time.Time `json:"hiredOn"`
}
var hires []hire
for rows.Next() {
var h hire
if err := rows.Scan(&h.Name, &h.Role, &h.Score, &h.When); err != nil {
return Failf(CodeFailed, "the applications could not be read")
}
hires = append(hires, h)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the applications could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"hires": hires,
// Named for what it is. "count" would read as "hires in this
// period", which it is not once a limit is applied.
"listed": len(hires),
}
if len(hires) == 0 {
data["note"] = "No hires match that. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Hire performance ───────────────────────────────────────────────────── */
// HiresPerformance reports how hired workers are performing since joining.
func HiresPerformance(db repo.Querier) Tool {
return Tool{
Name: "hires_performance",
Description: "Read how hired workers are performing: average Krow score, " +
"reliability, attendance and client rating across the workforce, plus the " +
"strongest and weakest performers. Use for questions about whether hires are " +
"working out and who needs support.",
InputSchema: periodSchema("How many workers to list at each end. Defaults to 5."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "worker-profiles")
if denied != nil {
return *denied
}
in, _, _, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
// Every one of these is 0 for a worker nobody has rated yet — the
// product renders that as "Not yet scored" (dataResolver.js) and its
// lowest band starts above 0 (TalentPool.jsx). Averaging the zeros in
// reported a 1.6-of-5 client rating for a workforce rated 4.7.
var (
total, scored int64
krow, reliability, attendance, perf, ratings *float64
)
err := db.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE krow_score > 0),
avg(krow_score) FILTER (WHERE krow_score > 0),
avg(reliability_score) FILTER (WHERE reliability_score > 0),
avg(attendance_score) FILTER (WHERE attendance_score > 0),
avg(performance_score) FILTER (WHERE performance_score > 0),
avg(client_rating) FILTER (WHERE client_rating > 0)
FROM worker_profiles
WHERE `+q.clause(), q.args...,
).Scan(&total, &scored, &krow, &reliability, &attendance, &perf, &ratings)
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
top, err := workersByScore(ctx, db, q, in.limitOr(5), "DESC")
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
bottom, err := workersByScore(ctx, db, q, in.limitOr(5), "ASC")
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
data := map[string]any{
"workers": total,
"scored": scored,
"unscored": total - scored,
"strongest": top,
"weakest": bottom,
}
putAvg(data, "averageKrowScore", krow)
putAvg(data, "averageReliability", reliability)
putAvg(data, "averageAttendance", attendance)
putAvg(data, "averagePerformance", perf)
if ratings != nil {
data["averageClientRating"] = round1(*ratings)
}
if total == 0 {
data["note"] = "No worker profiles are visible. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
type scoredWorker struct {
Name string `json:"name"`
KrowScore *int `json:"krowScore,omitempty"`
Reliability *int `json:"reliability,omitempty"`
Attendance *int `json:"attendance,omitempty"`
}
func workersByScore(ctx context.Context, db repo.Querier, q *query, limit int, dir string) ([]scoredWorker, error) {
// `dir` is never caller input — it is one of two literals chosen here, so
// there is no path by which an identifier reaches the statement from
// outside this file.
if dir != "ASC" {
dir = "DESC"
}
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx, `
SELECT full_name, nullif(krow_score, 0), nullif(reliability_score, 0),
nullif(attendance_score, 0)
FROM worker_profiles
WHERE `+q.clause()+` AND krow_score > 0
ORDER BY krow_score `+dir+`, full_name ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []scoredWorker
for rows.Next() {
var w scoredWorker
if err := rows.Scan(&w.Name, &w.KrowScore, &w.Reliability, &w.Attendance); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
/* ── Positions at risk ──────────────────────────────────────────────────── */
// PositionsRisk reports roles that are struggling to fill.
func PositionsRisk(db repo.Querier) Tool {
return Tool{
Name: "positions_risk",
Description: "Read which open roles are at risk: how many applicants each has, " +
"how many are strong, how long each has been open, and its priority. Use for " +
"questions about roles that are hard to fill, urgent openings, and where " +
"attention is needed.",
InputSchema: periodSchema("How many roles to list, most at risk first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorizeAs(tc, "job-postings", "p")
if denied != nil {
return *denied
}
in, _, _, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
q.raw("p.status = 'active'")
// Counted before the limit is applied. Reporting len(roles) here
// made "how many roles are open" mean "how many I chose to show",
// so workspace_summary and this tool disagreed about the same
// number on the same data — the exact contradiction a reader would
// catch and a model would not.
var openRoles int64
if err := db.QueryRow(ctx,
`SELECT count(*) FROM job_postings p WHERE `+q.clause(), q.args...).Scan(&openRoles); err != nil {
return Failf(CodeFailed, "the job postings could not be read")
}
// The applicant counts are a correlated subquery rather than a join
// plus a Go-side tally: counting in SQL keeps the count behind the
// same predicate as the row it belongs to.
args := append(append([]any{}, q.args...), in.limitOr(10))
rows, err := db.Query(ctx, `
SELECT p.title, p.priority::text, p.headcount, p.created_date,
(SELECT count(*) FROM job_applications a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id),
(SELECT count(*) FROM job_applications a
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.ai_score >= 80)
FROM job_postings p
WHERE `+q.clause()+`
ORDER BY p.priority ASC, p.created_date ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the job postings could not be read")
}
defer rows.Close()
type role struct {
Title string `json:"title"`
Priority string `json:"priority"`
Headcount *int `json:"headcount,omitempty"`
DaysOpen int `json:"daysOpen"`
Applicants int64 `json:"applicants"`
Strong int64 `json:"strongApplicants"`
Risk string `json:"risk"`
}
var roles []role
now := time.Now()
for rows.Next() {
var r role
var created time.Time
if err := rows.Scan(&r.Title, &r.Priority, &r.Headcount, &created, &r.Applicants, &r.Strong); err != nil {
return Failf(CodeFailed, "the job postings could not be read")
}
r.DaysOpen = int(now.Sub(created).Hours() / 24)
r.Risk = riskFor(r.Strong, r.DaysOpen, r.Priority)
roles = append(roles, r)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the job postings could not be read")
}
data := map[string]any{"openRoles": openRoles, "roles": roles}
if int64(len(roles)) < openRoles {
data["omittedRoles"] = openRoles - int64(len(roles))
}
if openRoles == 0 {
data["note"] = "No roles are open. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
// riskFor labels a role.
//
// Stated as a rule rather than left to the model, because it is a judgement the
// product makes consistently — two agents describing the same role differently
// is worse than a label that is sometimes debatable. The model is free to
// disagree in prose; the label is what makes lists sortable.
func riskFor(strong int64, daysOpen int, priority string) string {
switch {
case strong == 0 && daysOpen > 14:
return "high"
case strong == 0 || (priority == "urgent" && strong < 2):
return "medium"
default:
return "low"
}
}
/* ── Talent pool ────────────────────────────────────────────────────────── */
// TalentPool reports the bench: who is available and how ready.
func TalentPool(db repo.Querier) Tool {
return Tool{
Name: "talent_pool",
Description: "Read the talent pool: how many workers are on the bench, their " +
"readiness by score band, average experience, and the most job-ready. Use for " +
"questions about available talent, bench depth, and who could be placed.",
InputSchema: periodSchema("How many workers to list, most ready first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "worker-profiles")
if denied != nil {
return *denied
}
in, _, _, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
var (
total, ready, developing, early, unscored int64
experienced int64
avgExperience *float64
)
err := db.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE krow_score >= 80),
count(*) FILTER (WHERE krow_score >= 60 AND krow_score < 80),
count(*) FILTER (WHERE krow_score > 0 AND krow_score < 60),
count(*) FILTER (WHERE krow_score = 0),
count(*) FILTER (WHERE experience_years > 0),
avg(experience_years) FILTER (WHERE experience_years > 0)
FROM worker_profiles
WHERE `+q.clause(), q.args...,
).Scan(&total, &ready, &developing, &early, &unscored, &experienced, &avgExperience)
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
top, err := workersByScore(ctx, db, q, in.limitOr(10), "DESC")
if err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
data := map[string]any{
"workers": total,
"jobReady": ready,
"developing": developing,
"early": early,
"unscored": unscored,
"mostReady": top,
}
// Averaged over the workers who state any experience, so say so
// rather than letting an unstated 0 read as a first-year worker.
if avgExperience != nil {
data["averageExperienceYears"] = round1(*avgExperience)
data["averageExperienceBasis"] = experienced
}
if total == 0 {
data["note"] = "The talent pool is empty. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Shared helpers ─────────────────────────────────────────────────────── */
type counted struct {
Value string `json:"value"`
Count int64 `json:"count"`
}
// groupCount is `GROUP BY one column` behind the caller's predicate.
//
// The column name is supplied by this package, never by input — every call site
// below passes a literal.
func groupCount(ctx context.Context, db repo.Querier, table, column string, q *query, limit int) ([]counted, error) {
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx,
`SELECT `+column+`, count(*) FROM `+table+` WHERE `+q.clause()+
` GROUP BY 1 ORDER BY count(*) DESC, 1 ASC LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []counted
for rows.Next() {
var c counted
if err := rows.Scan(&c.Value, &c.Count); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func putAvg(data map[string]any, key string, v *float64) {
if v != nil {
data[key] = int(*v + 0.5)
}
}

View File

@@ -0,0 +1,246 @@
package tools_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/testutil"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// An ai_score of 0 is the absence of a score, not a score of zero. The product
// states this rule in candidateIntelligence.js: "null rather than zeros ... so
// an unscreened candidate shows '—' instead of a confident-looking 0".
//
// candidates_quality shipped without a test and broke the rule three ways: it
// counted every unscored candidate as weak, dragged the average down with their
// zeros, and offered no way to tell how many the average was over. On the real
// corpus that reported "16 weak, averaging 28" where the truth was "1 weak, 9
// scored, averaging 76" — a number that would push a manager to reject a pool
// that is in fact strong.
func TestCandidateQualityTreatsAnAbsentScoreAsAbsentNotAsZero(t *testing.T) {
h := testutil.New(t)
f := seedFunnel(t, h, "quality-zero") // Dana: ai_screened, scored 88
reg := liveRegistry(t, h)
ctx := context.Background()
// Three more, sharing Dana's org and posting.
for _, c := range []struct {
name, email, status string
score int
}{
{"Unscored Applicant", "u1-quality@example.test", "applied", 0},
{"Advanced Unscored", "u2-quality@example.test", "interview", 0},
{"Genuinely Weak", "u3-quality@example.test", "ai_screened", 30},
} {
if _, err := h.Pool.Exec(ctx, `
INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score)
SELECT org_id, job_posting_id, $2, $3, $4::application_status, $5
FROM job_applications WHERE id = $1::uuid`,
f.appID, c.name, c.email, c.status, c.score); err != nil {
t.Fatalf("seed %s: %v", c.name, err)
}
}
res := reg.Dispatch(ctx,
tools.Context{Principal: f.admin, RunID: "run_q1"},
"candidates_quality", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("candidates_quality failed: %+v", res.Error)
}
var got struct {
Applications int `json:"applications"`
Strong int `json:"strong"`
Weak int `json:"weak"`
Unscreened int `json:"unscreened"`
Scored int `json:"scored"`
Average int `json:"averageMatchScore"`
Basis int `json:"averageMatchScoreBasis"`
}
body, _ := json.Marshal(res.Data)
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
// Only the 30 is weak. The two zeros are unscored, not bad.
for _, c := range []struct {
field string
got int
want int
}{
{"applications", got.Applications, 4},
{"strong", got.Strong, 1}, // 88
{"weak", got.Weak, 1}, // 30 only — not the two zeros
{"unscreened", got.Unscreened, 1}, // the one at 'applied'
{"scored", got.Scored, 2}, // 88 and 30
{"averageMatchScore", got.Average, 59}, // (88+30)/2, not (88+30+0+0)/4
{"averageMatchScoreBasis", got.Basis, 2},
} {
if c.got != c.want {
t.Errorf("%s = %d, want %d — full payload: %s", c.field, c.got, c.want, body)
}
}
}
// seedRatedWorkers creates an org with workers at the given krow scores. A score
// of 0 is how the product records "not yet rated" — worker_profiles.krow_score is
// NOT NULL, so there is no null to distinguish it, which is exactly the trap.
func seedRatedWorkers(t *testing.T, h *testutil.Harness, slug string,
workers []struct {
name string
krow int
rated float64
}) (string, authctx.Identity) {
t.Helper()
ctx := context.Background()
org := freshOrg(t, h, slug)
boss := fmt.Sprintf("boss-%s@example.test", slug)
admin := authctx.Identity{
UserID: seedUser(t, h, org, boss, "admin"),
OrgID: org, Role: "admin", Email: boss,
}
for i, w := range workers {
if _, err := h.Pool.Exec(ctx, `
INSERT INTO worker_profiles
(org_id, full_name, email, krow_score, reliability_score,
attendance_score, performance_score, client_rating)
VALUES ($1::uuid, $2, $3, $4, $4, $4, $4, $5)`,
org, w.name, fmt.Sprintf("w%d-%s@example.test", i, slug), w.krow, w.rated); err != nil {
t.Fatalf("seed worker %s: %v", w.name, err)
}
}
return org, admin
}
// A krow_score of 0 means "Not yet scored" — the product says so in as many
// words (dataResolver.js) and its lowest band starts above 0 (TalentPool.jsx).
// Reading it as a score of zero made hires_performance report the four unrated
// workers as the four *weakest* performers by name, and dragged every average
// down with them: a workforce rated 4.7 of 5 was reported at 1.6.
func TestPerformanceNeverNamesAnUnratedWorkerAsWeakest(t *testing.T) {
h := testutil.New(t)
org, admin := seedRatedWorkers(t, h, "perf-unrated", []struct {
name string
krow int
rated float64
}{
{"Scored High", 90, 5},
{"Scored Low", 50, 4},
{"Never Rated", 0, 0},
{"Also Never Rated", 0, 0},
})
_ = org
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: admin, RunID: "run_p1"},
"hires_performance", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("hires_performance failed: %+v", res.Error)
}
var got struct {
Workers int `json:"workers"`
Scored int `json:"scored"`
Unscored int `json:"unscored"`
AvgKrow int `json:"averageKrowScore"`
AvgRated float64 `json:"averageClientRating"`
Weakest []struct {
Name string `json:"name"`
} `json:"weakest"`
Strongest []struct {
Name string `json:"name"`
} `json:"strongest"`
}
body, _ := json.Marshal(res.Data)
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
// The headline the operator acts on.
for _, c := range []struct {
field string
got int
want int
}{
{"workers", got.Workers, 4},
{"scored", got.Scored, 2},
{"unscored", got.Unscored, 2},
{"averageKrowScore", got.AvgKrow, 70}, // (90+50)/2, not (90+50+0+0)/4 = 35
} {
if c.got != c.want {
t.Errorf("%s = %d, want %d — payload: %s", c.field, c.got, c.want, body)
}
}
if got.AvgRated != 4.5 { // (5+4)/2, not (5+4+0+0)/4 = 2.25
t.Errorf("averageClientRating = %v, want 4.5 — payload: %s", got.AvgRated, body)
}
// The part that names real people.
for _, w := range append(append([]struct {
Name string `json:"name"`
}{}, got.Weakest...), got.Strongest...) {
if w.Name == "Never Rated" || w.Name == "Also Never Rated" {
t.Errorf("%q has no rating but was ranked by score — payload: %s", w.Name, body)
}
}
if len(got.Weakest) == 0 || got.Weakest[0].Name != "Scored Low" {
t.Errorf("weakest should start at the lowest *rated* worker — payload: %s", body)
}
}
// TalentPool.jsx puts the lowest band at (krow_score || 0) > 0 && < 60, so an
// unrated worker is counted separately rather than as the least ready.
func TestTalentPoolCountsUnratedSeparatelyFromLowScoring(t *testing.T) {
h := testutil.New(t)
_, admin := seedRatedWorkers(t, h, "pool-bands", []struct {
name string
krow int
rated float64
}{
{"Ready", 90, 5},
{"Developing", 70, 4},
{"Early", 30, 3},
{"Unrated", 0, 0},
})
reg := liveRegistry(t, h)
res := reg.Dispatch(context.Background(),
tools.Context{Principal: admin, RunID: "run_p2"},
"talent_pool", json.RawMessage(`{}`))
if res.Error != nil {
t.Fatalf("talent_pool failed: %+v", res.Error)
}
var got struct {
Workers int `json:"workers"`
JobReady int `json:"jobReady"`
Developing int `json:"developing"`
Early int `json:"early"`
Unscored int `json:"unscored"`
}
body, _ := json.Marshal(res.Data)
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
for _, c := range []struct {
field string
got int
want int
}{
{"workers", got.Workers, 4},
{"jobReady", got.JobReady, 1},
{"developing", got.Developing, 1},
{"early", got.Early, 1}, // the 30 only — not the unrated worker
{"unscored", got.Unscored, 1},
} {
if c.got != c.want {
t.Errorf("%s = %d, want %d — payload: %s", c.field, c.got, c.want, body)
}
}
}

View File

@@ -0,0 +1,162 @@
package tools
import (
"context"
"encoding/json"
"strings"
"github.com/krow/krow-backend/go-api/internal/knowledge"
)
// knowledge_search: retrieval the model can drive.
//
// The loop already retrieves once, for the caller's opening question, and puts
// the result in a <context> block. That covers the common case and covers it
// cheaply — but it is one shot at one phrasing, and the phrasing is the user's.
// A question like "am I allowed to leave early on Fridays?" retrieves a
// paragraph about early departure and misses the one about shift-swap approvals
// that actually answers it.
//
// So the model gets a second bite: it may search again, in its own words, once
// it knows what it is looking for. That is worth a tool.
//
// What it emphatically does NOT get is a widening of scope. The sources come
// from the agent's spec by way of ctx.KnowledgeSources, exactly as the
// principal does; there is no `source` argument, because an argument is
// something a model can choose and the set of corpora an agent may read is not
// the model's to choose. All the model controls is the words.
type knowledgeSearchInput struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
// KnowledgeSearch builds the search tool.
//
// One registered tool, not one per agent: the per-agent part is the source
// list, and that travels on the Context beside the principal rather than being
// closed over. Both are set by the loop from records the conversation cannot
// touch, so two agents sharing this tool still cannot read each other's
// corpora — and the registry stays a flat set of names, which is what §3's
// publish-time validation resolves against.
func KnowledgeSearch(r *knowledge.Retriever) Tool {
return Tool{
Name: "knowledge_search",
Description: "Search the documents this agent has access to and get back passages " +
"with source ids. Use it when the answer depends on what a written policy, " +
"handbook or guide actually says — and search again in your own words if the " +
"first passages are close but not quite right. Cite the source id of anything " +
"you rely on.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "What to look for. Write it as the words you would expect to " +
"find in the document, not as a question to a person.",
},
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 20,
"description": "How many passages to return. Defaults to 8.",
},
},
"required": []string{"query"},
"additionalProperties": false,
},
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
if r == nil {
return Failf(CodeUnavailable, "there are no documents to search")
}
if len(tc.KnowledgeSources) == 0 {
// This agent's spec named no knowledge. Refused rather than
// widened: an empty source list is not permission to read
// everything, and the tool should not have been offered.
return Failf(CodeUnavailable, "this agent has no documents to search")
}
var in knowledgeSearchInput
if err := json.Unmarshal(inputs, &in); err != nil {
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
}
if strings.TrimSpace(in.Query) == "" {
return Failf(CodeInvalidInput, "a search needs something to search for")
}
limit := in.Limit
if limit <= 0 {
limit = knowledge.DefaultK
}
if limit > 20 {
limit = 20
}
// The principal is the caller's. Not the agent's, not a service
// account, and not anything the model supplied — I1 lives in this
// one line as much as anywhere in the package.
res, err := r.Retrieve(ctx, knowledge.Query{
Text: in.Query,
Principal: tc.Principal,
Sources: tc.KnowledgeSources,
K: limit,
})
if err != nil {
var kErr *knowledge.Error
if ok := asKnowledge(err, &kErr); ok && kErr.Code == knowledge.ErrNoPrincipal {
// A caller retrieval will not serve is refused the same way
// every other resource refuses one. Saying "you have no
// tenant" would be a more useful error and a worse one.
return Denied()
}
return Failf(CodeFailed, "the documents could not be searched")
}
passages := make([]map[string]any, 0, len(res.Chunks))
for _, c := range res.Chunks {
p := map[string]any{
// The id first, because citing it is the point. §5: a claim
// without a retrievable citation is inference, not grounded
// fact, and the model can only tell them apart if every
// passage arrived with an address.
"sourceId": c.ChunkID,
"title": c.Title,
"text": c.Text,
}
if c.Heading != "" {
p["section"] = c.Heading
}
if c.URI != "" {
p["uri"] = c.URI
}
passages = append(passages, p)
}
data := map[string]any{
"query": in.Query,
"passages": passages,
"count": len(passages),
}
if len(passages) == 0 {
data["note"] = "Nothing in these documents matched. This is a real answer, " +
"not a failure to look — say so rather than answering from general knowledge."
}
if res.DenseSkipped != "" {
// Handed to the model because it changes what an absence means.
// "I found nothing" is a weaker claim when half the index was
// not searched, and the model should be able to say which.
data["degraded"] = res.DenseSkipped
}
return OK(data)
},
}
}
// asKnowledge is errors.As for the knowledge package's error type.
func asKnowledge(err error, target **knowledge.Error) bool {
if e, ok := err.(*knowledge.Error); ok {
*target = e
return true
}
return false
}

View File

@@ -0,0 +1,465 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
)
// MaxToolsPerAgent is §8's cap.
//
// Not a technical limit. Past roughly twenty tools a model's choice degrades
// faster than the extra capability helps, and an agent that needs more is an
// agent that should be a parent with subagents. Enforced at resolution so a
// spec cannot quietly exceed it.
const MaxToolsPerAgent = 20
// Registry holds every tool this service can run.
//
// One registry, not one per agent: a spec selects from it by name, and a name
// that resolves to nothing fails at publish rather than at run time. That is
// the same rule the frontend registry already applies to skills.
type Registry struct {
mu sync.RWMutex
tools map[string]Tool
// confirmations is where a pending write waits for a person. Held by the
// registry rather than passed to Dispatch so that no call site can supply
// its own — a store is the thing that says a write was approved, and a
// caller able to swap it is a caller able to approve on the user's behalf.
confirmations Store
}
// NewRegistry builds an empty registry with an in-process confirmation store.
//
// Fine for one instance and for tests. A deployment with more than one replica
// must pass a shared store — see NewRegistryWithStore and the note on
// MemoryStore.
func NewRegistry() *Registry {
return NewRegistryWithStore(NewMemoryStore())
}
// NewRegistryWithStore builds a registry over a specific confirmation store.
func NewRegistryWithStore(s Store) *Registry {
if s == nil {
s = NewMemoryStore()
}
return &Registry{tools: make(map[string]Tool), confirmations: s}
}
// Register adds a tool, or returns why it cannot be added.
//
// Called at startup, so a malformed tool is a boot failure rather than a
// surprise on the first run that reaches it.
func (r *Registry) Register(t Tool) error {
if t.Name == "" {
return fmt.Errorf("tools: a tool needs a name")
}
if t.Handler == nil {
return fmt.Errorf("tools: %s has no handler", t.Name)
}
if t.Description == "" {
// The description is what the model reads instead of documentation. A
// tool without one is a tool that will be called wrongly.
return fmt.Errorf("tools: %s has no description", t.Name)
}
switch t.Effect {
case EffectRead:
case EffectWrite:
// I4, and the reason RequiresConfirmation is not left to the author:
// a write that forgot to set it would run unconfirmed forever, and
// nothing downstream could tell it apart from a deliberate choice.
t.RequiresConfirmation = true
default:
return fmt.Errorf("tools: %s declares effect %q, want read or write", t.Name, t.Effect)
}
// §8 step 3. A write with no renderer could only ever be confirmed by
// showing someone its raw arguments, and nobody can meaningfully approve
// a pair of uuids. Refused at registration, so it is a boot failure rather
// than a bad dialog discovered in production.
if t.RequiresConfirmation && t.Confirm == nil {
return fmt.Errorf(
"tools: %s needs confirmation but has no Confirm renderer; "+
"a write must be able to say in plain language what it will do", t.Name)
}
if t.Effect == EffectRead && t.Confirm != nil && !t.RequiresConfirmation {
// A renderer that will never run is a renderer nobody maintains, and
// the day the tool becomes a write it will be wrong.
return fmt.Errorf("tools: %s has a Confirm renderer but never asks for confirmation", t.Name)
}
if t.MaxResultBytes <= 0 {
t.MaxResultBytes = DefaultMaxResultBytes
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.tools[t.Name]; exists {
return fmt.Errorf("tools: %s is already registered", t.Name)
}
r.tools[t.Name] = t
return nil
}
// MustRegister adds a tool or panics. For startup wiring, where the alternative
// to a panic is a service that boots without a capability it claims to have.
func (r *Registry) MustRegister(t Tool) {
if err := r.Register(t); err != nil {
panic(err)
}
}
// Get returns a tool by name.
func (r *Registry) Get(name string) (Tool, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.tools[name]
return t, ok
}
// Names lists every registered tool, sorted.
//
// Sorted because this list is rendered into the prompt, and the prompt is a
// cache prefix: an unstable order would invalidate the cache on every request
// for no reason at all.
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
sort.Strings(names)
return names
}
// Resolve turns a spec's tool names into tools.
//
// Unknown names are returned rather than dropped: §3 says a spec naming a tool
// that does not exist fails validation at publish, and this is the function
// that lets publish say which one. At run time the caller decides — dropping a
// missing tool is better than refusing the run, but only if someone is told.
func (r *Registry) Resolve(names []string) (resolved []Tool, unknown []string, err error) {
if len(names) > MaxToolsPerAgent {
return nil, nil, fmt.Errorf(
"tools: %d tools requested, the cap is %d — split this agent into a parent with subagents",
len(names), MaxToolsPerAgent)
}
r.mu.RLock()
defer r.mu.RUnlock()
seen := make(map[string]bool, len(names))
for _, name := range names {
if seen[name] {
continue
}
seen[name] = true
t, ok := r.tools[name]
if !ok {
unknown = append(unknown, name)
continue
}
resolved = append(resolved, t)
}
return resolved, unknown, nil
}
// Dispatch runs one tool call and returns its result.
//
// This is the one place a tool is invoked, and it holds the two gates a handler
// must not be trusted to hold itself:
//
// 1. **The confirmation gate.** A write with no resolved confirmation never
// reaches its handler. The model cannot argue its way past this because the
// model is not consulted — the check is on the tool's declared effect and
// the token in the context, both of which are set outside the conversation.
// 2. **The truncation cap.** Applied to what the handler returned, with the
// flag set. A handler that forgets to bound its own output cannot flood the
// next turn.
//
// A panicking handler is contained here too. A tool is the least trusted code
// in the runtime — it is where new integrations land — and one bad handler
// must cost its own call, not the run.
func (r *Registry) Dispatch(ctx context.Context, tc Context, name string, inputs json.RawMessage) (res Result) {
t, ok := r.Get(name)
if !ok {
return Failf(CodeUnavailable, "there is no tool called %q", name)
}
// The gate. Everything past this line has either no effect or an approval.
if t.RequiresConfirmation {
if pending, blocked := r.gate(ctx, tc, t, inputs); blocked {
return pending
}
}
defer func() {
if p := recover(); p != nil {
res = Failf(CodeFailed, "%s failed unexpectedly", name)
}
}()
res = t.Handler(ctx, tc, inputs)
return truncate(res, t.MaxResultBytes)
}
// gate decides whether a confirmed tool may run now.
//
// Two outcomes, and the interesting thing is how few:
//
// - **A token bound to exactly this call.** Consumed, and the handler runs.
// - **Anything else.** Render what the call would do, issue a token bound to
// it, and return that as a pending Result. Nothing is written. The run ends
// at ConfirmationPending and a person is asked.
//
// "Anything else" deliberately includes a token that does not match: expired,
// already spent, issued to somebody else, or issued for a different worker on a
// different day. None of those is an error to report — they all mean the same
// thing, which is that nobody has approved THIS call, and the honest response
// to that is to describe it and ask. Returning a failure instead would leave
// the model holding an error it cannot act on, in a run whose token is fixed
// for its whole duration.
//
// A non-matching token is NOT consumed. An earlier version spent it on any
// attempt, reasoning that a mismatch was a replay or a guess. It is neither —
// the token is 24 random bytes, so guessing is not on the table — and the cost
// was real: a model that makes two write calls in one turn would destroy a
// perfectly good approval with whichever call happened to be dispatched first.
//
// Returns (result, true) when the call must not proceed.
func (r *Registry) gate(ctx context.Context, tc Context, t Tool, inputs json.RawMessage) (Result, bool) {
b := bind(tc, t.Name, inputs)
if tc.Confirmation != "" && r.confirmations != nil &&
r.confirmations.Resolve(ctx, tc.Confirmation, b) {
return Result{}, false
}
// Rendered under the same authorization as the write. A renderer that
// resolved a name the caller may not see would have leaked it in the act
// of asking permission not to.
confirmation, denied := r.render(ctx, tc, t, inputs)
if denied != nil {
return *denied, true
}
token, err := newToken()
if err != nil {
return Failf(CodeUnavailable, "%s could not be prepared for confirmation", t.Name), true
}
confirmation.Token = token
confirmation.Tool = t.Name
if confirmation.ExpiresAt.IsZero() {
confirmation.ExpiresAt = time.Now().Add(ConfirmationTTL)
}
if r.confirmations == nil {
return Failf(CodeUnavailable,
"%s cannot run: there is nowhere to record a confirmation", t.Name), true
}
if err := r.confirmations.Issue(ctx, b, confirmation); err != nil {
// Failing closed. A confirmation that was shown but not recorded is one
// that can never be honoured, and asking a person a question whose
// answer will be discarded is worse than saying the tool is unavailable.
return Failf(CodeUnavailable, "%s could not be prepared for confirmation", t.Name), true
}
return Result{Confirmation: confirmation}, true
}
// render runs a tool's Confirm renderer, containing its failures.
//
// A renderer is author-written code that runs before any approval exists, so it
// gets the same panic containment a handler does — and a renderer that returns
// nothing at all is treated as a refusal rather than as an empty dialog.
func (r *Registry) render(ctx context.Context, tc Context, t Tool, inputs json.RawMessage) (c *Confirmation, denied *Result) {
defer func() {
if p := recover(); p != nil {
failed := Failf(CodeFailed, "%s could not describe what it would do", t.Name)
c, denied = nil, &failed
}
}()
if t.Confirm == nil {
// Register refuses this, so reaching it means a Tool was built by hand
// and bypassed registration. Fail closed rather than trust it.
failed := Failf(CodeUnavailable, "%s cannot describe what it would do", t.Name)
return nil, &failed
}
c, denied = t.Confirm(ctx, tc, inputs)
if denied != nil {
return nil, denied
}
if c == nil {
failed := Failf(CodeUnavailable, "%s cannot describe what it would do", t.Name)
return nil, &failed
}
return c, nil
}
// truncate bounds a result, marking it when it had to.
//
// The data is replaced wholesale rather than cut mid-encoding: a JSON document
// sliced at a byte offset is not a JSON document, and a model handed one will
// either fail to parse it or — worse — parse the fragment and reason about it
// as if it were the whole.
func truncate(res Result, maxBytes int) Result {
if res.Data == nil || maxBytes <= 0 {
return res
}
encoded, err := json.Marshal(res.Data)
if err != nil {
return Failf(CodeFailed, "the result could not be encoded")
}
if len(encoded) <= maxBytes {
return res
}
return Result{
Data: map[string]any{
"note": fmt.Sprintf(
"This result was %d bytes, over the %d-byte limit, and has been withheld rather than cut. "+
"Ask again with a narrower filter, a period, or a smaller limit.",
len(encoded), maxBytes),
},
Truncated: true,
}
}
/* ── Redeeming an approval ──────────────────────────────────────────────── */
// Redeemed is what an approved write did.
type Redeemed struct {
// Tool is the tool that ran, so the caller can record and report it.
Tool string
// Inputs are the arguments a person approved, replayed verbatim.
Inputs json.RawMessage
Result Result
}
// DispatchApproved performs the call a token authorised.
//
// The other half of the confirmation flow, and the one that makes it reliable.
//
// The original design had only one path: the model, on a resumed turn, makes
// the same tool call again, and the token is matched against it. That works
// when it works and fails silently when it does not — a model is not
// deterministic, and asked a second time it may reasonably seek clarification
// instead of repeating itself. Observed: a person clicked Approve, the model
// asked a follow-up question, the token was never presented, and nothing
// happened. No error. No write. Nothing to tell the user why.
//
// So this path does not ask the model anything. It takes the token, gets back
// the exact call that was described to the person, and performs THAT. What
// somebody approved and what happens are the same thing by construction rather
// than by the model's cooperation.
//
// The gate is not bypassed — this IS the gate. Redeem checks the caller, the
// tenant and the expiry and consumes the token atomically, so an approval still
// buys exactly one write and only for the person who was asked.
//
// Returns ok=false when the token authorises nothing: unknown, expired, spent,
// or somebody else's. Indistinguishably, as everywhere else.
func (r *Registry) DispatchApproved(ctx context.Context, tc Context, token string) (out Redeemed, ok bool) {
if r.confirmations == nil || token == "" {
return Redeemed{}, false
}
approved, ok := r.confirmations.Redeem(ctx, token, Principal{
UserID: tc.Principal.UserID,
OrgID: tc.Principal.OrgID,
})
if !ok {
return Redeemed{}, false
}
t, exists := r.Get(approved.Tool)
if !exists {
// The tool was withdrawn between the asking and the answering. The
// token is spent either way — it has been consumed by Redeem — which is
// correct: re-offering it would let a person approve something this
// service can no longer describe.
return Redeemed{
Tool: approved.Tool,
Inputs: approved.Inputs,
Result: Failf(CodeUnavailable, "%s is no longer available", approved.Tool),
}, true
}
res := func() (res Result) {
defer func() {
if p := recover(); p != nil {
res = Failf(CodeFailed, "%s failed unexpectedly", t.Name)
}
}()
return t.Handler(ctx, tc, approved.Inputs)
}()
return Redeemed{
Tool: approved.Tool,
Inputs: approved.Inputs,
Result: truncate(res, t.MaxResultBytes),
}, true
}
// ToolInfo is what a tool looks like to somebody choosing one, rather than to
// the model calling it.
//
// The InputSchema is deliberately absent: an author picks a capability, and the
// schema is the model's business. Effect is present because it is the one thing
// an author must understand — a write tool means their agent can propose
// changes, which a person will then be asked to approve.
type ToolInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Effect string `json:"effect"`
RequiresConfirmation bool `json:"requiresConfirmation"`
}
// Catalogue lists every registered tool, sorted, as choosable metadata.
//
// This exists so an agent author can be shown the real tool set rather than a
// hand-maintained copy of it in the frontend. A second list would drift, and
// the failure would be silent: an author picks a tool that no longer exists and
// gets an agent that quietly cannot do the thing they picked.
func (r *Registry) Catalogue() []ToolInfo {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]ToolInfo, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, ToolInfo{
Name: t.Name,
Description: t.Description,
Effect: string(t.Effect),
RequiresConfirmation: t.RequiresConfirmation,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Known reports whether every name is a registered tool, returning the ones
// that are not.
//
// §3 requires an unknown tool name to fail validation at PUBLISH. Without this
// the runtime records and drops the name, so a typo becomes an agent that is
// silently missing a capability its author believes it has.
func (r *Registry) Known(names []string) (unknown []string) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, n := range names {
if _, ok := r.tools[n]; !ok {
unknown = append(unknown, n)
}
}
return unknown
}

View File

@@ -0,0 +1,180 @@
package tools_test
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/tools"
)
func stub(name string, effect tools.Effect, h tools.Handler) tools.Tool {
if h == nil {
h = func(context.Context, tools.Context, json.RawMessage) tools.Result {
return tools.OK(map[string]any{"ok": true})
}
}
t := tools.Tool{
Name: name, Description: "A stub.", Effect: effect,
InputSchema: map[string]any{"type": "object"}, Handler: h,
}
if effect == tools.EffectWrite {
t.Confirm = describeStub
}
return t
}
// describeStub is the minimum a write must be able to say about itself.
func describeStub(context.Context, tools.Context, json.RawMessage) (*tools.Confirmation, *tools.Result) {
return &tools.Confirmation{Title: "Do the thing", Summary: "The thing will be done."}, nil
}
func TestWriteToolsAlwaysRequireConfirmation(t *testing.T) {
// I4, and the reason it is forced rather than trusted: a write that forgot
// to set the flag would run unconfirmed forever, indistinguishable from a
// deliberate choice.
reg := tools.NewRegistry()
w := stub("send_shift_offer", tools.EffectWrite, nil)
w.RequiresConfirmation = false // an author trying to opt out
reg.MustRegister(w)
got, _ := reg.Get("send_shift_offer")
if !got.RequiresConfirmation {
t.Fatal("a write tool must require confirmation regardless of what its author declared")
}
res := reg.Dispatch(context.Background(), tools.Context{}, "send_shift_offer", nil)
if res.Confirmation == nil {
t.Fatal("an unconfirmed write must come back as a question, not run")
}
if res.Data != nil {
t.Fatal("an unconfirmed write must not produce a result")
}
}
func TestAWriteWithNoRendererIsRefusedAtRegistration(t *testing.T) {
// §8 step 3. A confirmation nobody can read is a click, not a confirmation,
// and the only honest dialog for a tool with no renderer would show raw
// arguments. Caught at boot rather than in production.
reg := tools.NewRegistry()
w := stub("silent_write", tools.EffectWrite, nil)
w.Confirm = nil
if err := reg.Register(w); err == nil {
t.Fatal("a write with no Confirm renderer must be refused")
}
}
func TestRegisterRejectsMalformedTools(t *testing.T) {
reg := tools.NewRegistry()
cases := map[string]tools.Tool{
"no name": {Description: "x", Effect: tools.EffectRead, Handler: stub("x", tools.EffectRead, nil).Handler},
"no handler": {Name: "a", Description: "x", Effect: tools.EffectRead},
"no description": {Name: "b", Effect: tools.EffectRead, Handler: stub("b", tools.EffectRead, nil).Handler},
"bad effect": {Name: "c", Description: "x", Effect: "maybe", Handler: stub("c", tools.EffectRead, nil).Handler},
}
for name, tool := range cases {
if err := reg.Register(tool); err == nil {
t.Errorf("%s: should have been refused", name)
}
}
if err := reg.Register(stub("ok", tools.EffectRead, nil)); err != nil {
t.Errorf("a well-formed tool was refused: %v", err)
}
if err := reg.Register(stub("ok", tools.EffectRead, nil)); err == nil {
t.Error("a duplicate name should be refused")
}
}
func TestResolveEnforcesThePerAgentCap(t *testing.T) {
reg := tools.NewRegistry()
names := make([]string, 0, tools.MaxToolsPerAgent+1)
for i := 0; i <= tools.MaxToolsPerAgent; i++ {
n := string(rune('a'+i%26)) + strings.Repeat("x", i)
reg.MustRegister(stub(n, tools.EffectRead, nil))
names = append(names, n)
}
if _, _, err := reg.Resolve(names); err == nil {
t.Errorf("%d tools should exceed the cap of %d", len(names), tools.MaxToolsPerAgent)
}
}
func TestResolveReportsUnknownNames(t *testing.T) {
reg := tools.NewRegistry()
reg.MustRegister(stub("real", tools.EffectRead, nil))
resolved, unknown, err := reg.Resolve([]string{"real", "imaginary", "real"})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if len(resolved) != 1 {
t.Errorf("resolved %d tools, want 1 — a repeated name is not two tools", len(resolved))
}
if len(unknown) != 1 || unknown[0] != "imaginary" {
t.Errorf("unknown = %v, want [imaginary] — a missing name must be reported, not dropped", unknown)
}
}
func TestDispatchContainsAPanickingHandler(t *testing.T) {
// A tool is the least trusted code in the runtime. One bad handler costs
// its own call, not the run.
reg := tools.NewRegistry()
reg.MustRegister(stub("explodes", tools.EffectRead,
func(context.Context, tools.Context, json.RawMessage) tools.Result {
panic("boom")
}))
res := reg.Dispatch(context.Background(), tools.Context{}, "explodes", nil)
if res.Error == nil || res.Error.Code != tools.CodeFailed {
t.Fatalf("a panicking handler should return a failure, got %+v", res)
}
}
func TestDispatchWithholdsOversizedResults(t *testing.T) {
// Cut JSON is not JSON, and a model handed a fragment reasons about it as
// if it were whole. The result is withheld and the fact declared.
reg := tools.NewRegistry()
big := strings.Repeat("x", 2000)
tool := stub("huge", tools.EffectRead,
func(context.Context, tools.Context, json.RawMessage) tools.Result {
return tools.OK(map[string]any{"blob": big})
})
tool.MaxResultBytes = 500
reg.MustRegister(tool)
res := reg.Dispatch(context.Background(), tools.Context{}, "huge", nil)
if !res.Truncated {
t.Fatal("an oversized result must be marked truncated")
}
encoded, _ := json.Marshal(res.Data)
if strings.Contains(string(encoded), big) {
t.Error("the oversized payload was returned anyway")
}
if !strings.Contains(string(encoded), "narrower") {
t.Error("the model was not told how to ask again")
}
}
func TestDispatchUnknownToolIsAResultNotAPanic(t *testing.T) {
reg := tools.NewRegistry()
res := reg.Dispatch(context.Background(), tools.Context{}, "nope", nil)
if res.Error == nil || res.Error.Code != tools.CodeUnavailable {
t.Fatalf("want unavailable, got %+v", res)
}
}
func TestNamesAreSortedForCacheStability(t *testing.T) {
// The tool list is part of the cached prefix. An unstable order would
// invalidate the cache on every request for no reason at all.
reg := tools.NewRegistry()
for _, n := range []string{"zulu", "alpha", "mike"} {
reg.MustRegister(stub(n, tools.EffectRead, nil))
}
got := reg.Names()
want := []string{"alpha", "mike", "zulu"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("Names() = %v, want %v", got, want)
}
}
}

View File

@@ -0,0 +1,164 @@
package tools
import (
"fmt"
"strings"
"github.com/krow/krow-backend/go-api/internal/domain"
)
// query is a WHERE clause being assembled, with its bind parameters.
//
// Every tool builds its predicate through this rather than by hand. §13 names
// "passing the tenant id as a plain function argument through five layers" as
// an anti-pattern, and a hand-written `WHERE org_id = ?` at twenty call sites
// is the same failure wearing a different hat: twenty chances to forget, and
// the one that forgets is a cross-tenant read nobody notices until someone
// reports seeing another company's numbers.
//
// The only way to obtain one is authorize(), which cannot return a query
// without having first checked the policy and pinned the tenant.
type query struct {
where []string
args []any
// alias qualifies every column this query names, for the one handler that
// joins. Set through withAlias before any predicate is added, so a column
// cannot be added unqualified and then become ambiguous when a second
// table arrives.
alias string
}
// authorize resolves a caller against a resource's policy and returns the
// predicate their rows are behind.
//
// This is the first line of every handler body. It answers both of §2's
// retrieval questions at once:
//
// - **May this caller list this resource at all?** From the policy table,
// deny-by-default. An unlisted role, an unknown resource and a caller with
// no tenant all refuse.
// - **Which rows are theirs?** The talent scope, rendered as SQL. A
// pre-filter, per I2 — pushed into the query so counts, shares and
// rankings are all computed over exactly the caller's own rows.
//
// The returned Result is non-nil exactly when the caller is refused, and it is
// always the same opaque Denied(): a handler must return it unchanged rather
// than explaining, because two distinguishable refusals are an oracle.
func authorize(tc Context, resourcePath string) (*query, *Result) {
return authorizeOp(tc, resourcePath, domain.OpList, "")
}
// authorizeAs is authorize for a query whose table carries an alias.
func authorizeAs(tc Context, resourcePath, alias string) (*query, *Result) {
return authorizeOp(tc, resourcePath, domain.OpList, alias)
}
// authorizeOp is authorize for an operation other than listing.
//
// A write tool asks for OpCreate here, and the answer is a different set of
// roles: `assignments` lists to everyone and creates for operators only, so a
// talent caller who may perfectly well read their own roster is refused when
// they try to put themselves on one. Reusing the read check for a write would
// have granted exactly that — the most common way an authorization table gets
// quietly bypassed is by asking it the wrong question.
//
// The returned query still carries the caller's READ predicate. A write tool
// uses it to check that the rows it is about to reference are ones this caller
// could have seen: creating an assignment against a posting you cannot read is
// a write that confirms the posting exists.
func authorizeOp(tc Context, resourcePath string, op domain.Op, alias string) (*query, *Result) {
res, ok := domain.ResourceByPath[resourcePath]
if !ok || res.Policy == nil {
denied := Denied()
return nil, &denied
}
role, ok := domain.ParseRole(tc.Principal.Role)
if !ok || !res.Policy.Allows(op, role) {
denied := Denied()
return nil, &denied
}
// I5. No tenant means no query — there is no "all organizations" read, and
// a missing org is a bug upstream rather than a wildcard.
if tc.OrgID() == "" {
denied := Denied()
return nil, &denied
}
q := &query{args: []any{tc.OrgID()}, alias: alias}
q.where = []string{q.col("org_id") + " = $1::uuid"}
switch scope := res.Policy.ScopeFor(role); scope.Kind {
case domain.ScopeNone:
// Operators see the whole tenant. That is what an operator console is.
case domain.ScopeUserID:
q.eq(scope.Column+"::text", tc.Principal.UserID)
case domain.ScopeEmail:
q.eq(scope.Column, tc.Principal.Email)
case domain.ScopeActivePostings:
// Visibility rather than ownership: a talent caller sees the roles they
// could apply to, not the drafts, the paused roles or the closed
// history.
// "active" — the value repo.go renders for this scope. The enum has no
// "open" member, and a status literal that does not exist matches
// nothing, which fails safe and silently.
q.eq(scope.Column+"::text", "active")
case domain.ScopeOwnApplications:
// Ownership by reference. The subquery is the pre-filter — resolving
// the ids in Go first and filtering afterwards would be the
// post-filter I2 forbids.
q.args = append(q.args, tc.Principal.Email)
q.where = append(q.where, fmt.Sprintf(
"%s IN (SELECT id FROM job_applications WHERE org_id = $1::uuid AND email = $%d)",
q.col(scope.Column), len(q.args)))
default:
// An unrecognised scope kind matches nothing rather than everything.
// The safe direction to fail, and loud enough to find.
q.where = append(q.where, "false")
}
return q, nil
}
// withAlias qualifies this query's columns with a table alias.
//
// Called before any predicate is added — including the ones authorize() itself
// adds — so it is threaded through authorizeAs rather than applied afterwards.
func (q *query) col(name string) string {
if q.alias == "" {
return name
}
return q.alias + "." + name
}
// eq adds `column = value`.
func (q *query) eq(column string, value any) {
q.args = append(q.args, value)
q.where = append(q.where, fmt.Sprintf("%s = $%d", q.col(column), len(q.args)))
}
// gte adds `column >= value`.
func (q *query) gte(column string, value any) {
q.args = append(q.args, value)
q.where = append(q.where, fmt.Sprintf("%s >= $%d", q.col(column), len(q.args)))
}
// lt adds `column < value`.
func (q *query) lt(column string, value any) {
q.args = append(q.args, value)
q.where = append(q.where, fmt.Sprintf("%s < $%d", q.col(column), len(q.args)))
}
// raw adds a predicate with no bind parameters.
//
// For constant conditions only — a status literal, a NOT NULL. Never for
// anything derived from input: the whole point of eq/gte/lt is that a value
// cannot reach the statement except as a parameter.
func (q *query) raw(predicate string) {
q.where = append(q.where, predicate)
}
// clause renders the WHERE body.
func (q *query) clause() string { return strings.Join(q.where, " AND ") }

View File

@@ -0,0 +1,187 @@
// Package tools is the tool layer: everything an agent can do that is not
// talking.
//
// A tool is a named, schema'd function the model may call. The contract below
// is §4's, and three parts of it are load-bearing rather than stylistic:
//
// - **Every handler authorizes on ctx.Principal, first line.** A handler that
// reads ctx for anything except authorization is wrong. This is where I1
// lives: an agent may read exactly what its caller could read directly, and
// the only way to guarantee that is for the tool — not the model, not the
// prompt — to apply the caller's own permissions.
// - **Filters are pre-filters.** A handler narrows in SQL, never in Go over a
// fetched result set. I2: post-filtering leaks through counts and ranking
// positions even when no forbidden row is ever printed.
// - **Errors are returned, not raised.** A tool that panics or returns a bare
// error takes the whole run with it. The runtime decides whether the model
// sees a failure and retries, and it can only decide that if the failure
// arrives as data.
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/krow/krow-backend/go-api/internal/authctx"
)
// Effect is whether a tool changes anything.
type Effect string
const (
// EffectRead observes. Safe to call without asking anyone.
EffectRead Effect = "read"
// EffectWrite writes, sends, deletes, charges or notifies. Never runs
// without a resolved confirmation — see Tool.RequiresConfirmation.
EffectWrite Effect = "write"
)
// DefaultMaxResultBytes caps a tool result.
//
// Not a performance guard. An unbounded result is an unbounded prompt on the
// next turn, which is an unbounded bill and eventually a context overflow that
// presents as the model ignoring the middle of its own evidence.
const DefaultMaxResultBytes = 262_144
// Context is what a handler is given about its caller.
//
// Carries the principal, the tenant, the run and what budget is left, per §4.
// Deliberately a struct and not a context.Context value: a handler must not be
// able to *forget* to read it, and a compile error is a better reminder than a
// convention.
type Context struct {
// Principal is the caller the agent is acting for. Never the agent.
Principal authctx.Identity
// RunID addresses the trajectory this call is recorded in.
RunID string
// RemainingTokens is what the run has left to spend. A handler may use it
// to decide how much to return; it must not use it to decide whether the
// caller is allowed something.
RemainingTokens int64
// Confirmation is the resolved token for a write. Empty on a read, and
// empty on a write that has not been confirmed yet — which the dispatcher
// refuses before a handler is ever reached.
Confirmation string
// AgentID is the running agent, for the record. Never for authorization —
// what a caller may do is decided by their principal, and an agent that
// could widen that by being named would be an agent that expands access.
AgentID string
// KnowledgeSources are the corpora the running agent's SPEC declares.
//
// Here rather than in a tool argument, and the difference is the whole
// security property: an argument is something a model can choose, and which
// documents an agent may read is not the model's to choose. The loop sets
// this from the agent record; nothing in the conversation can reach it.
//
// Empty means this agent has no knowledge. It does not mean "all of it" —
// retrieval refuses an empty source list for exactly that reason.
KnowledgeSources []string
}
// OrgID is the tenant this call runs inside. I5 — every handler's query
// narrows by it, and there is no path that produces a call without one.
func (c Context) OrgID() string { return c.Principal.OrgID }
// Handler runs one tool.
//
// The signature is `(inputs, ctx)` in §4's terms, with the Go context first by
// convention so cancellation and the deadline reach the query. A handler
// returns a Result and never an error: a failure is a value the runtime routes,
// not a panic that ends a run.
type Handler func(ctx context.Context, tc Context, inputs json.RawMessage) Result
// Tool is one callable capability.
type Tool struct {
Name string
Description string
// InputSchema is JSON Schema. Every field described, because the
// description is what the model reads instead of documentation — and a
// tool whose schema demands an id the model was never given is a design
// bug, not a prompt problem. Add a lookup tool instead.
InputSchema map[string]any
Effect Effect
// RequiresConfirmation is forced true for a write by Register. It is a
// field rather than a method so a read tool may opt in — some reads are
// expensive enough to be worth asking about — but a write can never opt
// out. The model does not get a say either way.
RequiresConfirmation bool
// Confirm renders, in plain language, what this tool will do if approved.
// Mandatory when RequiresConfirmation is set: Register refuses a write
// without one, because a confirmation a person cannot read is not a
// confirmation, it is a click. See confirm.go.
Confirm Confirmer
MaxResultBytes int
Handler Handler
}
// Result is what a tool returns.
//
// Structured data, never prose: formatting is the model's job, and a handler
// that returns a sentence has decided how the answer reads before the model has
// seen the question.
type Result struct {
Data any `json:"data,omitempty"`
Error *ToolError `json:"error,omitempty"`
// Truncated says the result was cut at MaxResultBytes. Set alongside the
// data that survived — never as a silent drop, because a model given a
// truncated list with no marker will reason about it as if it were whole.
Truncated bool `json:"truncated,omitempty"`
// Confirmation is set when a write was described but not performed. It is
// neither success nor error: nothing happened, and something must now be
// approved by a person before anything can. The loop reads this and ends
// the run at ConfirmationPending rather than handing it to the model —
// see I4, and the note on Registry.Dispatch.
Confirmation *Confirmation `json:"confirmation,omitempty"`
}
// ToolError is a failure a handler chose to report.
type ToolError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// Failf builds an error result.
func Failf(code, format string, args ...any) Result {
return Result{Error: &ToolError{Code: code, Message: fmt.Sprintf(format, args...)}}
}
// OK builds a success result.
func OK(data any) Result { return Result{Data: data} }
// Standard tool error codes. A denial is deliberately one code with one
// wording — see Denied.
const (
CodeDenied = "tool.denied"
CodeInvalidInput = "tool.invalid_input"
CodeUnavailable = "tool.unavailable"
CodeFailed = "tool.failed"
)
// Denied is the single refusal every handler returns when a caller may not do
// something.
//
// One code, one message, no detail. §8: a denial must not reveal that the
// resource exists. Two different refusals — "no such venue" and "not your
// venue" — are an oracle: a caller who can tell them apart can enumerate what
// they cannot see, and the agent will happily run that enumeration for them one
// question at a time.
func Denied() Result {
return Result{Error: &ToolError{
Code: CodeDenied,
Message: "the caller does not have access to this",
}}
}

View File

@@ -0,0 +1,383 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// The workforce tools: attendance, overtime and shift coverage.
//
// Ports of `workforce.attendance`, `workforce.overtime` and
// `workforce.coverage`, which between them are named by eighteen of the shipped
// skills — the densest cluster in the registry.
//
// All three read shift_records, and all three go through authorize(), so a
// talent caller sees their own shifts and an operator sees the tenant's. The
// aggregate is computed after the predicate, never before: "the workforce
// averaged 4% late" computed over rows a caller may not read is a leak even
// though no row is printed.
//
// shift_status is `present | late | absent | no_show` — there is no
// "completed", "scheduled" or "cancelled" member. A status literal that is not
// in the enum matches no rows and raises nothing, so a wrong guess here reads
// as a quiet zero rather than an error. The values below are the enum's own.
type periodInput struct {
Period string `json:"period"`
Limit int `json:"limit"`
}
func (p periodInput) limitOr(n int) int {
if p.Limit <= 0 {
return n
}
if p.Limit > 100 {
return 100
}
return p.Limit
}
// decodePeriod reads the shared period/limit arguments.
func decodePeriod(inputs json.RawMessage) (periodInput, time.Time, time.Time, *Result) {
var in periodInput
if len(inputs) > 0 {
if err := json.Unmarshal(inputs, &in); err != nil {
r := Failf(CodeInvalidInput, "the arguments were not valid JSON")
return in, time.Time{}, time.Time{}, &r
}
}
from, to, err := windowFor(in.Period, time.Now())
if err != nil {
r := Failf(CodeInvalidInput, "%s", err.Error())
return in, time.Time{}, time.Time{}, &r
}
return in, from, to, nil
}
// periodSchema is the shared argument shape. One definition so three tools
// cannot describe the same argument three slightly different ways — the model
// reads these as documentation, and inconsistent documentation is worse than
// terse documentation.
func periodSchema(limitHelp string) map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"period": map[string]any{
"type": "string",
"enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"},
"description": "The window to read. Omit for all recorded history. " +
"Windows are computed from the current date; do not pass a date.",
},
"limit": map[string]any{
"type": "integer", "minimum": 1, "maximum": 100,
"description": limitHelp,
},
},
"additionalProperties": false,
}
}
/* ── Attendance ─────────────────────────────────────────────────────────── */
// WorkforceAttendance reports shift attendance: completion, lateness, no-shows.
func WorkforceAttendance(db repo.Querier) Tool {
return Tool{
Name: "workforce_attendance",
Description: "Read shift attendance: how many shifts were scheduled, completed, " +
"missed or started late, the average minutes late, and the workers with the " +
"weakest attendance. Use for questions about reliability, no-shows, lateness " +
"and whether shifts are being worked.",
InputSchema: periodSchema("How many workers to list, worst attendance first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "shift-records")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("shift_date", from)
q.lt("shift_date", to)
}
var (
total, completed, missed, noShow, late int64
avgLate *float64
)
err := db.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE status IN ('present', 'late')),
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
count(*) FILTER (WHERE status = 'no_show'),
count(*) FILTER (WHERE minutes_late > 0),
avg(minutes_late) FILTER (WHERE minutes_late > 0)
FROM shift_records
WHERE `+q.clause(), q.args...,
).Scan(&total, &completed, &missed, &noShow, &late, &avgLate)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
workers, err := weakestAttendance(ctx, db, q, in.limitOr(10))
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"shiftsScheduled": total,
"shiftsWorked": completed,
"shiftsMissed": missed,
"noShows": noShow,
"lateStarts": late,
"workers": workers,
}
if avgLate != nil {
data["averageMinutesLateWhenLate"] = int(*avgLate + 0.5)
}
if total > 0 {
data["completionRatePercent"] = int(float64(completed)/float64(total)*100 + 0.5)
} else {
data["note"] = "No shifts match that. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
type workerAttendance struct {
Worker string `json:"worker"`
Shifts int64 `json:"shifts"`
Missed int64 `json:"missed"`
NoShows int64 `json:"noShows"`
Late int64 `json:"lateStarts"`
Reliable int `json:"reliabilityPercent"`
AvgMinute int `json:"averageMinutesLate,omitempty"`
}
func weakestAttendance(ctx context.Context, db repo.Querier, q *query, limit int) ([]workerAttendance, error) {
args := append(append([]any{}, q.args...), limit)
rows, err := db.Query(ctx, `
SELECT worker_name,
count(*),
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
count(*) FILTER (WHERE status = 'no_show'),
count(*) FILTER (WHERE minutes_late > 0),
coalesce(avg(minutes_late) FILTER (WHERE minutes_late > 0), 0)
FROM shift_records
WHERE `+q.clause()+`
GROUP BY worker_name
ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC,
count(*) FILTER (WHERE minutes_late > 0) DESC,
worker_name ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []workerAttendance
for rows.Next() {
var w workerAttendance
var avg float64
if err := rows.Scan(&w.Worker, &w.Shifts, &w.Missed, &w.NoShows, &w.Late, &avg); err != nil {
return nil, err
}
w.AvgMinute = int(avg + 0.5)
if w.Shifts > 0 {
w.Reliable = int(float64(w.Shifts-w.Missed)/float64(w.Shifts)*100 + 0.5)
}
out = append(out, w)
}
return out, rows.Err()
}
/* ── Overtime ───────────────────────────────────────────────────────────── */
// WorkforceOvertime reports overtime hours and who is accruing them.
func WorkforceOvertime(db repo.Querier) Tool {
return Tool{
Name: "workforce_overtime",
Description: "Read overtime: total overtime hours, how many shifts ran over, and " +
"the workers accruing the most. Use for questions about overtime cost, who is " +
"working beyond their scheduled hours, and whether overtime is concentrated.",
InputSchema: periodSchema("How many workers to list, most overtime first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "shift-records")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("shift_date", from)
q.lt("shift_date", to)
}
var (
shiftsWithOT int64
totalOT float64
totalActual float64
)
err := db.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE overtime_hours > 0),
coalesce(sum(overtime_hours), 0),
coalesce(sum(actual_hours), 0)
FROM shift_records
WHERE `+q.clause(), q.args...,
).Scan(&shiftsWithOT, &totalOT, &totalActual)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
args := append(append([]any{}, q.args...), in.limitOr(10))
rows, err := db.Query(ctx, `
SELECT worker_name, coalesce(sum(overtime_hours), 0), count(*) FILTER (WHERE overtime_hours > 0)
FROM shift_records
WHERE `+q.clause()+`
GROUP BY worker_name
HAVING sum(overtime_hours) > 0
ORDER BY sum(overtime_hours) DESC, worker_name ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
defer rows.Close()
type worker struct {
Worker string `json:"worker"`
Hours float64 `json:"overtimeHours"`
Shifts int64 `json:"shiftsWithOvertime"`
}
var workers []worker
for rows.Next() {
var w worker
if err := rows.Scan(&w.Worker, &w.Hours, &w.Shifts); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
workers = append(workers, w)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"totalOvertimeHours": round1(totalOT),
"shiftsWithOvertime": shiftsWithOT,
"workers": workers,
}
if totalActual > 0 {
data["overtimeSharePercent"] = int(totalOT/totalActual*100 + 0.5)
}
if totalOT == 0 {
data["note"] = "No overtime was recorded in that window. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
/* ── Coverage ───────────────────────────────────────────────────────────── */
// WorkforceCoverage reports whether shifts are covered and what is unfilled.
func WorkforceCoverage(db repo.Querier) Tool {
return Tool{
Name: "workforce_coverage",
Description: "Read shift coverage: how many shifts are scheduled, cancelled or " +
"unworked, and which roles have the most uncovered shifts. Use for questions " +
"about gaps in the rota, roles that are hard to staff, and what is at risk of " +
"going unworked.",
InputSchema: periodSchema("How many roles to list, most uncovered first. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "shift-records")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("shift_date", from)
q.lt("shift_date", to)
}
args := append(append([]any{}, q.args...), in.limitOr(10))
rows, err := db.Query(ctx, `
SELECT coalesce(nullif(role, ''), 'unspecified'),
count(*),
count(*) FILTER (WHERE status IN ('no_show', 'absent')),
count(*) FILTER (WHERE status = 'late')
FROM shift_records
WHERE `+q.clause()+`
GROUP BY 1
ORDER BY count(*) FILTER (WHERE status IN ('no_show', 'absent')) DESC, 1 ASC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
defer rows.Close()
type roleCoverage struct {
Role string `json:"role"`
Shifts int64 `json:"shifts"`
Uncovered int64 `json:"uncovered"`
Late int64 `json:"lateStarts"`
Covered int `json:"coveragePercent"`
}
var (
roles []roleCoverage
allShifts, allUncovered, sch int64
)
for rows.Next() {
var r roleCoverage
if err := rows.Scan(&r.Role, &r.Shifts, &r.Uncovered, &r.Late); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
if r.Shifts > 0 {
r.Covered = int(float64(r.Shifts-r.Uncovered)/float64(r.Shifts)*100 + 0.5)
}
allShifts += r.Shifts
allUncovered += r.Uncovered
sch += r.Late
roles = append(roles, r)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
data := map[string]any{
"period": periodOrAll(in.Period),
"shifts": allShifts,
"uncovered": allUncovered,
"lateStarts": sch,
"roles": roles,
}
if allShifts > 0 {
data["coveragePercent"] = int(float64(allShifts-allUncovered)/float64(allShifts)*100 + 0.5)
} else {
data["note"] = "No shifts match that. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
func round1(f float64) float64 {
return float64(int(f*10+0.5)) / 10
}

View File

@@ -0,0 +1,388 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// The cross-domain tools: the workspace summary, activity signals, training,
// and operational risk.
//
// These are the ones that read more than one resource, and each one authorizes
// **per resource** rather than once at the top. That distinction is the whole
// of I1 here: a caller who may read shifts but not applications gets the shift
// half of the answer and a stated gap, never a blended figure computed over
// rows they cannot see. A single check at the entrance would have to pick one
// resource to check against, and whichever it picked would be wrong for the
// others.
/* ── Workspace summary ──────────────────────────────────────────────────── */
// WorkspaceSummary is the one-screen state of the workspace.
func WorkspaceSummary(db repo.Querier) Tool {
return Tool{
Name: "workspace_summary",
Description: "Read the overall state of the workspace: open roles, applications in " +
"flight, workers on the books, shifts recorded and recent activity volume. Use " +
"for broad questions about how things are going, and to decide which narrower " +
"tool to reach for next.",
InputSchema: periodSchema("Unused by this tool."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
data := map[string]any{"period": periodOrAll(in.Period)}
var withheld []string
// Each count is behind its own resource's policy. A resource the
// caller may not list is reported as withheld rather than omitted:
// a missing number reads as a zero, and a zero is a claim.
counts := []struct {
key, resource, table, extra string
}{
{"openRoles", "job-postings", "job_postings", "status = 'active'"},
{"applications", "job-applications", "job_applications", ""},
{"workers", "worker-profiles", "worker_profiles", ""},
{"shiftsRecorded", "shift-records", "shift_records", ""},
{"activityEvents", "user-activity", "user_activity", ""},
}
for _, c := range counts {
q, denied := authorize(tc, c.resource)
if denied != nil {
withheld = append(withheld, c.key)
continue
}
if c.extra != "" {
q.raw(c.extra)
}
if !from.IsZero() {
q.gte(dateColumnFor(c.table), from)
q.lt(dateColumnFor(c.table), to)
}
var n int64
if err := db.QueryRow(ctx,
`SELECT count(*) FROM `+c.table+` WHERE `+q.clause(), q.args...).Scan(&n); err != nil {
return Failf(CodeFailed, "the workspace could not be read")
}
data[c.key] = n
}
if len(withheld) > 0 {
data["withheld"] = withheld
data["withheldNote"] = "These figures are not available to this caller and are " +
"absent rather than zero. Do not describe them as zero or as empty."
}
return OK(data)
},
}
}
// dateColumnFor names the column a period filters on.
//
// shift_records is dated by when the shift happened, not by when the row was
// written — a shift entered late would otherwise land in the wrong week, which
// is exactly the kind of quiet wrongness a rota question cannot tolerate.
func dateColumnFor(table string) string {
if table == "shift_records" {
return "shift_date"
}
return "created_date"
}
/* ── Activity signals ───────────────────────────────────────────────────── */
// ActivitySignals surfaces activity that departs from the pattern.
func ActivitySignals(db repo.Querier) Tool {
return Tool{
Name: "activity_signals",
Description: "Find activity that departs from the usual pattern: days with unusual " +
"volume, accounts acting far more than others, and event kinds that appeared " +
"for the first time recently. Use only for questions about what looks unusual — " +
"for plain counts use activity_breakdown instead.",
InputSchema: periodSchema("How many signals to return. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
q, denied := authorize(tc, "user-activity")
if denied != nil {
return *denied
}
in, from, to, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
if !from.IsZero() {
q.gte("created_date", from)
q.lt("created_date", to)
}
// Daily volume, so "unusual" is measured against this workspace's
// own baseline rather than a number chosen here. A workspace that
// logs 4 events a day and one that logs 4,000 both get a threshold
// that means something.
rows, err := db.Query(ctx, `
SELECT date_trunc('day', created_date)::date, count(*)
FROM user_activity
WHERE `+q.clause()+`
GROUP BY 1 ORDER BY 1 ASC`, q.args...)
if err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
defer rows.Close()
type day struct {
Day time.Time `json:"day"`
Count int64 `json:"count"`
}
var (
days []day
total int64
)
for rows.Next() {
var d day
if err := rows.Scan(&d.Day, &d.Count); err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
total += d.Count
days = append(days, d)
}
if err := rows.Err(); err != nil {
return Failf(CodeFailed, "the activity log could not be read")
}
if len(days) < 3 {
// Below three days there is no pattern to depart from. Saying so
// is the honest answer; inventing a threshold would produce
// confident nonsense on a new workspace.
return OK(map[string]any{
"period": periodOrAll(in.Period),
"days": len(days),
"signals": []any{},
"note": "There is not enough history to say what is unusual. " +
"At least three days of activity are needed before a departure from " +
"the pattern means anything.",
})
}
mean := float64(total) / float64(len(days))
var variance float64
for _, d := range days {
diff := float64(d.Count) - mean
variance += diff * diff
}
stddev := sqrt(variance / float64(len(days)))
type signal struct {
Kind string `json:"kind"`
Day time.Time `json:"day,omitempty"`
Detail string `json:"detail"`
Count int64 `json:"count"`
}
var signals []signal
// Two standard deviations. Flagging ordinary activity trains the
// reader to ignore the flag, which is the Activity Agent's own
// stated instruction.
for _, d := range days {
if stddev > 0 && float64(d.Count) > mean+2*stddev {
signals = append(signals, signal{
Kind: "unusual-volume", Day: d.Day, Count: d.Count,
Detail: fmt.Sprintf("%d events against a daily average of %.0f", d.Count, mean),
})
}
}
if len(signals) > in.limitOr(10) {
signals = signals[:in.limitOr(10)]
}
data := map[string]any{
"period": periodOrAll(in.Period),
"days": len(days),
"averagePerDay": int(mean + 0.5),
"signals": signals,
"thresholdExplained": "A day is flagged when it exceeds the average by more " +
"than two standard deviations of this workspace's own daily volume.",
}
if len(signals) == 0 {
data["note"] = "Nothing departs from the pattern. This is a real answer, not a failure to look."
}
return OK(data)
},
}
}
// sqrt without importing math for one call.
func sqrt(f float64) float64 {
if f <= 0 {
return 0
}
x := f
for i := 0; i < 24; i++ {
x = (x + f/x) / 2
}
return x
}
/* ── Training ───────────────────────────────────────────────────────────── */
// WorkforceTraining reports learning progress across the workforce.
func WorkforceTraining(db repo.Querier) Tool {
return Tool{
Name: "workforce_training",
Description: "Read training and development: how many courses are available, how " +
"far the workforce has progressed, average profile completion and experience " +
"level. Use for questions about upskilling, course uptake and readiness.",
InputSchema: periodSchema("How many courses to list. Defaults to 20."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
in, _, _, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
data := map[string]any{}
var withheld []string
if q, denied := authorize(tc, "courses"); denied == nil {
var total, active int64
if err := db.QueryRow(ctx, `
SELECT count(*), count(*) FILTER (WHERE status = 'active')
FROM courses WHERE `+q.clause(), q.args...).Scan(&total, &active); err != nil {
return Failf(CodeFailed, "the courses could not be read")
}
data["courses"] = total
data["activeCourses"] = active
} else {
withheld = append(withheld, "courses")
}
if q, denied := authorize(tc, "worker-profiles"); denied == nil {
var (
workers int64
completion, xp *float64
)
if err := db.QueryRow(ctx, `
SELECT count(*), avg(profile_completion), avg(xp)
FROM worker_profiles WHERE `+q.clause(), q.args...,
).Scan(&workers, &completion, &xp); err != nil {
return Failf(CodeFailed, "the worker profiles could not be read")
}
data["workers"] = workers
putAvg(data, "averageProfileCompletion", completion)
putAvg(data, "averageXP", xp)
} else {
withheld = append(withheld, "workers")
}
_ = in
if len(withheld) > 0 {
data["withheld"] = withheld
data["withheldNote"] = "These figures are not available to this caller and are " +
"absent rather than zero. Do not describe them as zero or as empty."
}
return OK(data)
},
}
}
/* ── Operational risk ───────────────────────────────────────────────────── */
// OperationsRisk finds what is going wrong across domains.
func OperationsRisk(db repo.Querier) Tool {
return Tool{
Name: "operations_risk",
Description: "Find operational problems across hiring and the workforce at once: " +
"strong candidates waiting on a decision, applications nobody has screened, " +
"roles open a long time with no strong applicant, and shifts going unworked. " +
"Use for questions about what needs attention.",
InputSchema: periodSchema("How many findings per category. Defaults to 10."),
Effect: EffectRead,
MaxResultBytes: DefaultMaxResultBytes,
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
in, _, _, bad := decodePeriod(inputs)
if bad != nil {
return *bad
}
limit := in.limitOr(10)
type finding struct {
Kind string `json:"kind"`
Detail string `json:"detail"`
Count int64 `json:"count"`
}
var (
findings []finding
withheld []string
)
if q, denied := authorize(tc, "job-applications"); denied == nil {
var waiting, unscreened int64
if err := db.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE ai_score >= 80 AND status IN ('applied', 'ai_screened', 'shortlisted')),
count(*) FILTER (WHERE status = 'applied')
FROM job_applications WHERE `+q.clause(), q.args...,
).Scan(&waiting, &unscreened); err != nil {
return Failf(CodeFailed, "the applications could not be read")
}
if waiting > 0 {
findings = append(findings, finding{
Kind: "decision-owed", Count: waiting,
Detail: "strong candidates are waiting on a decision",
})
}
if unscreened > 0 {
findings = append(findings, finding{
Kind: "screening-backlog", Count: unscreened,
Detail: "applications have not been screened",
})
}
} else {
withheld = append(withheld, "applications")
}
if q, denied := authorize(tc, "shift-records"); denied == nil {
var unworked int64
if err := db.QueryRow(ctx, `
SELECT count(*) FROM shift_records
WHERE `+q.clause()+` AND status IN ('no_show', 'absent')`, q.args...,
).Scan(&unworked); err != nil {
return Failf(CodeFailed, "the shift records could not be read")
}
if unworked > 0 {
findings = append(findings, finding{
Kind: "shifts-unworked", Count: unworked,
Detail: "shifts were not worked",
})
}
} else {
withheld = append(withheld, "shifts")
}
if len(findings) > limit {
findings = findings[:limit]
}
data := map[string]any{"findings": findings}
// An empty list means the operation is running, not that the check
// did not run. Said explicitly, because those read identically.
if len(findings) == 0 && len(withheld) == 0 {
data["note"] = "Nothing is flagged. The checks ran and found no problems — " +
"this is not a failure to look."
}
if len(withheld) > 0 {
data["withheld"] = withheld
data["withheldNote"] = "These areas could not be checked for this caller. " +
"Do not describe them as having no problems."
}
return OK(data)
},
}
}